From f8423ebea413605b42de23f5dd06c5479d3fa1dd Mon Sep 17 00:00:00 2001 From: Ethan Werner Date: Sat, 10 Oct 2026 20:55:33 +0000 Subject: initial commit --- src/agent.cpp | 435 ++++++++++++ src/agent.h | 41 ++ src/compact.cpp | 596 +++++++++++++++++ src/compact.h | 124 ++++ src/config.cpp | 732 ++++++++++++++++++++ src/config.h | 106 +++ src/escape.c | 5 + src/goal.cpp | 241 +++++++ src/goal.h | 53 ++ src/input.cpp | 1949 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/input.h | 253 +++++++ src/limits.cpp | 275 ++++++++ src/limits.h | 40 ++ src/llm.h | 1722 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.cpp | 1902 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/mcp.cpp | 1010 ++++++++++++++++++++++++++++ src/mcp.h | 112 ++++ src/message.cpp | 1 + src/message.h | 367 ++++++++++ src/provider.cpp | 941 ++++++++++++++++++++++++++ src/provider.h | 41 ++ src/session.cpp | 1555 +++++++++++++++++++++++++++++++++++++++++++ src/session.h | 293 ++++++++ src/shell.cpp | 303 +++++++++ src/shell.h | 49 ++ src/tools.cpp | 1341 +++++++++++++++++++++++++++++++++++++ src/tools.h | 118 ++++ src/util.cpp | 628 ++++++++++++++++++ src/util.h | 110 +++ src/verbose.cpp | 55 ++ src/verbose.h | 35 + 31 files changed, 15433 insertions(+) create mode 100644 src/agent.cpp create mode 100644 src/agent.h create mode 100644 src/compact.cpp create mode 100644 src/compact.h create mode 100644 src/config.cpp create mode 100644 src/config.h create mode 100644 src/escape.c create mode 100644 src/goal.cpp create mode 100644 src/goal.h create mode 100644 src/input.cpp create mode 100644 src/input.h create mode 100644 src/limits.cpp create mode 100644 src/limits.h create mode 100644 src/llm.h create mode 100644 src/main.cpp create mode 100644 src/mcp.cpp create mode 100644 src/mcp.h create mode 100644 src/message.cpp create mode 100644 src/message.h create mode 100644 src/provider.cpp create mode 100644 src/provider.h create mode 100644 src/session.cpp create mode 100644 src/session.h create mode 100644 src/shell.cpp create mode 100644 src/shell.h create mode 100644 src/tools.cpp create mode 100644 src/tools.h create mode 100644 src/util.cpp create mode 100644 src/util.h create mode 100644 src/verbose.cpp create mode 100644 src/verbose.h (limited to 'src') diff --git a/src/agent.cpp b/src/agent.cpp new file mode 100644 index 0000000..d1357b7 --- /dev/null +++ b/src/agent.cpp @@ -0,0 +1,435 @@ +#include "agent.h" + +#include +#include +#include +#include +#include + +static char *trim_slashes(char *s) { + size_t n = strlen(s); + while (n && s[n - 1] == '/') + s[--n] = '\0'; + return s; +} + +void provider_probe_free(provider_probe *p) { + free(p->detail); + p->detail = NULL; +} + +const char *agent_default_base_url(provider_kind p) { + switch (p) { + case PROV_OPENAI: + return "https://api.openai.com/v1"; + case PROV_ANTHROPIC: + return "https://api.anthropic.com"; + case PROV_OLLAMA: + return "http://localhost:11434"; + } + return "https://api.openai.com/v1"; +} + +char *agent_models_url(const config *cfg) { + char *base = xstrdup(cfg->base_url ? cfg->base_url + : agent_default_base_url(cfg->provider)); + trim_slashes(base); + xbuf u; + xbuf_init(&u); + xbuf_put(&u, base); + switch (cfg->provider) { + case PROV_OPENAI: + xbuf_push(&u, '/'); + xbuf_put(&u, "models"); + break; + case PROV_ANTHROPIC: { + /* The canonical base is api.anthropic.com; the /v1 prefix belongs to + * the path. If the caller already included /v1 in base_url, don't + * duplicate it (avoids .../v1/v1/models). */ + size_t blen = strlen(base); + if (blen >= 3 && memcmp(base + blen - 3, "/v1", 3) == 0) + xbuf_put(&u, "/models?limit=100"); + else + xbuf_put(&u, "/v1/models?limit=100"); + break; + } + case PROV_OLLAMA: { + xbuf_put(&u, "/api/show?name="); + char *enc = cfg->model ? curl_easy_escape(NULL, cfg->model, 0) : NULL; + xbuf_put(&u, enc ? enc : ""); + free(enc); + break; + } + } + char *out = xbuf_steal(&u); + free(base); + return out; +} + +void agent_auth(const config *cfg, char **header, char **value) { + if (header) + *header = NULL; + if (value) + *value = NULL; + switch (cfg->provider) { + case PROV_OPENAI: + if (cfg->api_key) { + if (header) + *header = xstrdup("Authorization"); + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "Bearer %s", cfg->api_key); + if (value) + *value = xbuf_steal(&b); + else + xbuf_free(&b); + } + break; + case PROV_ANTHROPIC: + if (cfg->api_key) { + if (header) + *header = xstrdup("x-api-key"); + if (value) + *value = xstrdup(cfg->api_key); + } + break; + case PROV_OLLAMA: + break; + } +} + +int agent_http_get(const char *url, const char *auth_header, + const char *auth_value, long *status, char **body, + char **err) { + if (body) + *body = NULL; + if (err) + *err = NULL; + CURL *c = curl_easy_init(); + if (!c) { + if (err) + *err = xstrdup("could not initialize curl"); + return -1; + } + xbuf b; + xbuf_init(&b); + struct curl_slist *h = NULL; + if (auth_header && auth_value) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", auth_header, auth_value); + h = curl_slist_append(h, hb.data); + xbuf_free(&hb); + curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); + } + curl_easy_setopt(c, CURLOPT_URL, url); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 5L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(c, CURLOPT_CAINFO, ca); + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, xbuf_curl_write); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &b); + CURLcode rc = curl_easy_perform(c); + curl_slist_free_all(h); + if (rc != CURLE_OK) { + char *e = xstrdup(curl_easy_strerror(rc)); + free(b.data); + curl_easy_cleanup(c); + if (err) + *err = e; + return -1; + } + long code = 0; + curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code); + if (status) + *status = code; + if (body) + *body = xbuf_steal(&b); + else + free(b.data); + curl_easy_cleanup(c); + return 0; +} + +static bool str_eq(const cJSON *v, const char *s) { + return cJSON_IsString(v) && v->valuestring && + strcmp(v->valuestring, s) == 0; +} + +static unsigned long num_u64(const cJSON *v) { + if (cJSON_IsNumber(v) && std::isfinite(v->valuedouble) && + v->valuedouble >= 0 && std::floor(v->valuedouble) == v->valuedouble && + v->valuedouble < (double)std::numeric_limits::max()) + return (unsigned long)v->valuedouble; + return 0; +} + +static bool openai_model_matches_entry(const cJSON *entry, const char *model) { + if (str_eq(cJSON_GetObjectItem(entry, "id"), model)) + return true; + if (str_eq(cJSON_GetObjectItem(entry, "name"), model)) + return true; + const cJSON *al = cJSON_GetObjectItem(entry, "aliases"); + if (cJSON_IsArray(al)) { + const cJSON *x = NULL; + cJSON_ArrayForEach(x, al) + if (str_eq(x, model)) + return true; + } + return false; +} + +bool openai_model_present(const char *body, const char *model) { + cJSON *v = cJSON_Parse(body); + if (!v) + return false; + bool found = false; + cJSON *arr = cJSON_GetObjectItem(v, "data"); + if (!arr) + arr = cJSON_GetObjectItem(v, "models"); + if (cJSON_IsArray(arr)) { + const cJSON *m = NULL; + cJSON_ArrayForEach(m, arr) + if (openai_model_matches_entry(m, model)) { + found = true; + break; + } + } + cJSON_Delete(v); + return found; +} + +bool anthropic_model_present(const char *body, const char *model) { + cJSON *v = cJSON_Parse(body); + if (!v) + return false; + bool found = false; + const cJSON *arr = cJSON_GetObjectItem(v, "data"); + if (cJSON_IsArray(arr)) { + const cJSON *m = NULL; + cJSON_ArrayForEach(m, arr) + if (str_eq(cJSON_GetObjectItem(m, "id"), model)) { + found = true; + break; + } + } + cJSON_Delete(v); + return found; +} + +unsigned long extract_context_limit(const cJSON *json, const config *cfg) { + if (!json) + return 0; + switch (cfg->provider) { + case PROV_OPENAI: { + unsigned long v = num_u64(cJSON_GetObjectItem(json, "context_length")); + if (v) + return v; + const cJSON *data = cJSON_GetObjectItem(json, "data"); + if (!cJSON_IsArray(data)) + return 0; + const cJSON *want = NULL; + const cJSON *m = NULL; + cJSON_ArrayForEach(m, data) + if (openai_model_matches_entry(m, cfg->model)) { + want = m; + break; + } + if (!want && data->child) + want = data->child; + if (!want) + return 0; + v = num_u64(cJSON_GetObjectItem(want, "context_length")); + if (v) + return v; + v = num_u64(cJSON_GetObjectItem(cJSON_GetObjectItem(want, "meta"), + "n_ctx")); + if (v) + return v; + return num_u64(cJSON_GetObjectItem(want, "context_window")); + } + case PROV_OLLAMA: { + unsigned long v = num_u64(cJSON_GetObjectItem( + cJSON_GetObjectItem(json, "model_details"), "context_length")); + if (v) + return v; + const cJSON *mf = cJSON_GetObjectItem(json, "modelfiles"); + if (cJSON_IsArray(mf) && mf->child) + v = num_u64(cJSON_GetObjectItem( + cJSON_GetObjectItem(mf->child, "parameters"), + "context_length")); + return v; + } + case PROV_ANTHROPIC: { + const cJSON *data = cJSON_GetObjectItem(json, "data"); + if (!cJSON_IsArray(data) || !data->child) + return 0; + const cJSON *want = NULL; + const cJSON *m = NULL; + cJSON_ArrayForEach(m, data) + if (str_eq(cJSON_GetObjectItem(m, "id"), cfg->model)) { + want = m; + break; + } + if (!want) + want = data->child; + return num_u64(cJSON_GetObjectItem(want, "max_tokens")); + } + } + return 0; +} + +unsigned long agent_fetch_context_limit(const config *cfg) { + char *url = agent_models_url(cfg); + char *h = NULL, *v = NULL; + agent_auth(cfg, &h, &v); + long status = 0; + char *body = NULL; + char *err = NULL; + int rc = agent_http_get(url, h, v, &status, &body, &err); + free(h); + free(v); + free(url); + free(err); + if (rc != 0) { + free(body); + return 0; + } + unsigned long limit = 0; + /* No status gate (parity): a non-2xx body just has to parse and carry + * the field. */ + cJSON *j = cJSON_Parse(body); + if (j) { + limit = extract_context_limit(j, cfg); + cJSON_Delete(j); + } + free(body); + (void)status; + return limit; +} + +static const char *status_phrase(long code) { + switch (code) { + case 200: return "200 OK"; + case 201: return "201 Created"; + case 204: return "204 No Content"; + case 400: return "400 Bad Request"; + case 401: return "401 Unauthorized"; + case 403: return "403 Forbidden"; + case 404: return "404 Not Found"; + case 405: return "405 Method Not Allowed"; + case 408: return "408 Request Timeout"; + case 429: return "429 Too Many Requests"; + case 500: return "500 Internal Server Error"; + case 502: return "502 Bad Gateway"; + case 503: return "503 Service Unavailable"; + case 504: return "504 Gateway Timeout"; + default: { + static char buf[32]; + snprintf(buf, sizeof buf, "%ld", code); + return buf; + } + } +} + +provider_probe agent_probe_provider(const config *cfg) { + provider_probe p = {false, NULL}; + char *base = xstrdup(cfg->base_url ? cfg->base_url + : agent_default_base_url(cfg->provider)); + trim_slashes(base); + char *url = agent_models_url(cfg); + char *h = NULL, *v = NULL; + agent_auth(cfg, &h, &v); + long status = 0; + char *body = NULL; + char *err = NULL; + if (agent_http_get(url, h, v, &status, &body, &err) != 0) { + xbuf fb; + xbuf_init(&fb); + xbuf_fmt(&fb, "could not reach %s (connection: %s); is the endpoint " + "up and reachable?", + base, err ? err : "unknown"); + free(err); + free(h); + free(v); + free(url); + free(base); + free(body); + p.detail = xbuf_steal(&fb); + return p; + } + free(h); + free(v); + free(url); + bool present = false; + switch (cfg->provider) { + case PROV_OPENAI: + present = openai_model_present(body ? body : "", cfg->model); + break; + case PROV_ANTHROPIC: + present = anthropic_model_present(body ? body : "", cfg->model); + break; + case PROV_OLLAMA: + present = (status >= 200 && status < 300); + break; + } + bool success = status >= 200 && status < 300; + bool client_err = status >= 400 && status < 500; + xbuf fb; + xbuf_init(&fb); + switch (cfg->provider) { + case PROV_OPENAI: + if (success) { + if (present) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found in the provider list at %s", + cfg->model, base); + } else { + xbuf_fmt(&fb, "model `%s` not in the provider's model list " + "at %s", + cfg->model, base); + } + } else if (client_err) { + xbuf_fmt(&fb, "provider returned %s for %s/models (check api_key)", + status_phrase(status), base); + } else { + xbuf_fmt(&fb, "provider returned %s for %s/models", + status_phrase(status), base); + } + break; + case PROV_ANTHROPIC: + if (success) { + if (present) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found in the provider list", + cfg->model); + } else { + xbuf_fmt(&fb, "model `%s` not in the provider's model list " + "at %s", + cfg->model, base); + } + } else if (client_err) { + xbuf_fmt(&fb, "provider returned %s for %s/v1/models (check " + "api_key)", + status_phrase(status), base); + } else { + xbuf_fmt(&fb, "provider returned %s for %s/v1/models", + status_phrase(status), base); + } + break; + case PROV_OLLAMA: + if (success) { + p.ok = true; + xbuf_fmt(&fb, "model `%s` found via Ollama", cfg->model); + } else { + xbuf_fmt(&fb, "Ollama returned %s at %s (model `%s`)", + status_phrase(status), base, cfg->model); + } + break; + } + free(body); + p.detail = xbuf_steal(&fb); + free(base); + return p; +} diff --git a/src/agent.h b/src/agent.h new file mode 100644 index 0000000..a18f55e --- /dev/null +++ b/src/agent.h @@ -0,0 +1,41 @@ +#ifndef LLSH_AGENT_H +#define LLSH_AGENT_H + +#include "config.h" + +#include + +/* Mirrors agent.rs: provider probe + context-limit fetch (libcurl). */ + +typedef struct { + bool ok; + char *detail; /* owned; release with provider_probe_free */ +} provider_probe; + +void provider_probe_free(provider_probe *p); + +const char *agent_default_base_url(provider_kind p); + +/* Model-list endpoint for the probe/fetch. Caller frees. */ +char *agent_models_url(const config *cfg); +/* Auth header for the model-list endpoint. Caller frees each non-NULL. + * Ollama sends no auth: both come back NULL. */ +void agent_auth(const config *cfg, char **header, char **value); + +/* GET `url` with a 5s timeout. 0 = HTTP response received (*status, *body + * owned); -1 = transport failure (*err owned, body NULL). */ +int agent_http_get(const char *url, const char *auth_header, + const char *auth_value, long *status, char **body, + char **err); + +/* Pull the window size out of a provider's model-info response. 0 = absent. */ +unsigned long extract_context_limit(const cJSON *json, const config *cfg); + +bool openai_model_present(const char *body, const char *model); +bool anthropic_model_present(const char *body, const char *model); + +/* Best effort: network failures / non-2xx / missing fields -> 0. */ +unsigned long agent_fetch_context_limit(const config *cfg); +provider_probe agent_probe_provider(const config *cfg); + +#endif diff --git a/src/compact.cpp b/src/compact.cpp new file mode 100644 index 0000000..65e2210 --- /dev/null +++ b/src/compact.cpp @@ -0,0 +1,596 @@ +#include "compact.h" + +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Preamble */ +/* ------------------------------------------------------------------ */ + +const char *compact_summarizer_preamble(void) { + return "You are a context compactor for a terminal coding agent. You are " + "given the transcript of the earlier part of that agent's " + "conversation, which no longer fits in the context window. Compress " + "it into a faithful, dense summary that lets the agent continue " + "seamlessly.\n" + "\n" + "Write ONLY the summary, in Markdown, with these exact sections in " + "this order:\n" + "\n" + "## Goal\n" + "What the user is trying to accomplish.\n" + "\n" + "## Decisions\n" + "Choices made and the reasons that still matter.\n" + "\n" + "## Work done\n" + "Concrete steps taken: files touched, commands run, and their " + "results.\n" + "\n" + "## Open issues\n" + "Errors, edge cases, or problems that remain unresolved.\n" + "\n" + "## Next steps\n" + "What should happen next.\n" + "\n" + "## Files\n" + "One bullet per file the agent read or edited, as `- `.\n" + "\n" + "Rules:\n" + "- Recall over precision: keep every fact the agent would otherwise " + "have to rediscover; drop nothing load-bearing.\n" + "- Be factual and terse; never invent details that are not in the " + "transcript.\n" + "- Discard raw tool output and verbatim file contents; summarize " + "their substance instead.\n" + "- Keep it under 800 words."; +} + +/* ------------------------------------------------------------------ */ +/* Config */ +/* ------------------------------------------------------------------ */ + +void compact_config_from(unsigned long context_limit, double compact_threshold, + double clear_threshold, compact_config *out) { + out->context_limit = context_limit; + out->compact_threshold = compact_threshold; + out->clear_threshold = clear_threshold; +} + +bool compact_at(const compact_config *c, unsigned long *out) { + if (c->compact_threshold <= 0.0) + return false; + *out = (unsigned long)(c->compact_threshold * (double)c->context_limit); + return true; +} + +bool clear_at(const compact_config *c, unsigned long *out) { + if (c->clear_threshold <= 0.0) + return false; + *out = (unsigned long)(c->clear_threshold * (double)c->context_limit); + return true; +} + +/* ------------------------------------------------------------------ */ +/* Token estimation */ +/* ------------------------------------------------------------------ */ + +static size_t trc_bytes(const tool_result_content &c) { + if (std::holds_alternative(c)) + return 8; + if (const auto *t = std::get_if(&c)) + return t->text.size(); + if (const auto *s = std::get_if(&c)) + return s->value.size(); + return 0; +} + +static size_t uc_bytes(const user_part &p) { + if (const auto *t = std::get_if(&p)) + return t->text.size(); + const auto &r = std::get(p); + size_t total = r.name.size(); + for (const auto &c : r.content) + total += trc_bytes(c); + return total; +} + +static size_t ac_bytes(const assistant_part &p) { + if (std::holds_alternative(p)) + return 0; + if (const auto *c = std::get_if(&p)) + return c->name.size() + c->args.size(); + if (const auto *t = std::get_if(&p)) + return t->text.size(); + if (const auto *r = std::get_if(&p)) + return r->text.size(); + return 0; +} + +static size_t message_bytes(const message *m) { + if (m->is_system()) + return m->system().text.size(); + size_t total = 0; + if (m->is_user()) { + for (const auto &p : m->user().parts) + total += uc_bytes(p); + } else if (m->is_assistant()) { + for (const auto &p : m->assistant().parts) + total += ac_bytes(p); + } + return total; +} + +unsigned long compact_estimate_tokens(const message *msgs, size_t n) { + unsigned long long bytes = 0; + for (size_t i = 0; i < n; i++) + bytes += message_bytes(&msgs[i]); + return (unsigned long)((bytes + 3) / 4); +} + +/* ------------------------------------------------------------------ */ +/* Boundary + clearing */ +/* ------------------------------------------------------------------ */ + +static bool compact_is_pure_text_user(const message *m) { + return m->is_user() && m->user().parts.size() == 1 && + std::holds_alternative(m->user().parts[0]); +} + +size_t compact_keep_boundary(const message *msgs, size_t n) { + for (size_t i = n; i-- > 0;) { + if (compact_is_pure_text_user(&msgs[i])) + return i; + } + return n; +} + +size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary) { + size_t cleared = 0; + for (size_t i = 0; i < boundary && i < n; i++) { + message *m = &msgs[i]; + if (!m->is_user()) + continue; + for (auto &p : m->user().parts) { + user_tool_result_part *r = std::get_if(&p); + if (!r) + continue; + bool already = false; + for (const auto &c : r->content) { + const trc_text *t = std::get_if(&c); + if (t && t->text == CLEARED_PLACEHOLDER) + already = true; + } + if (already) + continue; + r->content.clear(); + trc_text t; + t.text = CLEARED_PLACEHOLDER; + r->content.push_back(std::move(t)); + cleared++; + } + } + return cleared; +} + +/* ------------------------------------------------------------------ */ +/* Transcripts */ +/* ------------------------------------------------------------------ */ + +static void put_trc(xbuf *b, const tool_result_content &c) { + if (std::holds_alternative(c)) { + xbuf_put(b, "[image]"); + return; + } + if (const auto *t = std::get_if(&c)) { + if (!t->text.empty()) + xbuf_put(b, t->text.c_str()); + return; + } + if (const auto *s = std::get_if(&c)) + if (!s->value.empty()) + xbuf_put(b, s->value.c_str()); +} + +char *compact_transcript(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "System: "); + xbuf_put(&b, m->system().text.c_str()); + xbuf_push(&b, '\n'); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if(&p)) { + xbuf_put(&b, "User: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else { + const auto &r = std::get(p); + xbuf_fmt(&b, "ToolResult(%s): ", r.name.c_str()); + for (const auto &c : r.content) + put_trc(&b, c); + xbuf_push(&b, '\n'); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if(&p)) { + xbuf_put(&b, "Assistant: "); + xbuf_put(&b, t->text.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *c = std::get_if(&p)) { + xbuf_fmt(&b, "Assistant tool call %s: %s", + c->name.c_str(), c->args.c_str()); + xbuf_push(&b, '\n'); + } else if (const auto *r = std::get_if(&p)) { + xbuf_put(&b, "Assistant reasoning: "); + xbuf_put(&b, r->text.c_str()); + xbuf_push(&b, '\n'); + } + } + } + } + return xbuf_steal(&b); +} + +static void push_md_tool_result(xbuf *b, const tool_result_content &c) { + if (const auto *t = std::get_if(&c)) { + xbuf_put(b, t->text.c_str()); + xbuf_put(b, "\n\n"); + } else if (const auto *s = std::get_if(&c)) { + xbuf_put(b, "```json\n"); + xbuf_put(b, s->value.c_str()); + xbuf_put(b, "\n```\n\n"); + } else { + xbuf_put(b, "[image]\n\n"); + } +} + +char *compact_transcript_markdown(const message *msgs, size_t n) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "# llsh transcript\n\n"); + if (n == 0) { + xbuf_put(&b, "_No messages yet._\n"); + return xbuf_steal(&b); + } + for (size_t i = 0; i < n; i++) { + const message *m = &msgs[i]; + if (m->is_system()) { + xbuf_put(&b, "## System\n\n"); + xbuf_put(&b, m->system().text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if(&p)) { + xbuf_put(&b, "## User\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else { + const auto &r = std::get(p); + xbuf_fmt(&b, "### Tool result: `%s`\n\n", + r.name.c_str()); + for (const auto &c : r.content) + push_md_tool_result(&b, c); + } + } + } else if (m->is_assistant()) { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if(&p)) { + xbuf_put(&b, "## Assistant\n\n"); + xbuf_put(&b, t->text.c_str()); + xbuf_put(&b, "\n\n"); + } else if (const auto *c = std::get_if(&p)) { + xbuf_fmt(&b, "### Tool call: `%s`\n\n", + c->name.c_str()); + if (c->args.empty() || c->args == "null") { + xbuf_put(&b, "_no arguments_\n\n"); + } else { + xbuf_put(&b, "```json\n"); + xbuf_put(&b, c->args.c_str()); + xbuf_put(&b, "\n```\n\n"); + } + } else if (const auto *r = std::get_if(&p)) { + if (!r->text.empty()) { + xbuf_put(&b, "> **reasoning** "); + xbuf_put(&b, r->text.c_str()); + xbuf_put(&b, "\n\n"); + } + } + } + } + } + return xbuf_steal(&b); +} + +/* ------------------------------------------------------------------ */ +/* File-path extraction */ +/* ------------------------------------------------------------------ */ + +static bool starts_with_i(const char *s, size_t n, const char *prefix) { + size_t pn = strlen(prefix); + if (n < pn) + return false; + for (size_t i = 0; i < pn; i++) { + char a = s[i]; + if (a >= 'A' && a <= 'Z') + a = (char)(a - 'A' + 'a'); + if (a != prefix[i]) + return false; + } + return true; +} + +void compact_recent_file_paths(const char *summary, strlist *out) { + sl_init(out); + if (!summary) + return; + bool in_files = false; + const char *p = summary; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + const char *line_end = p + linelen; + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 0 && line[0] == '#') { + const char *h = line; + while (*h == '#') + h++; + size_t hl; + h = xtrim_span(h, (size_t)(line_end - h), &hl); + in_files = starts_with_i(h, hl, "files"); + } else if (in_files && tlen > 0 && (line[0] == '-' || line[0] == '*')) { + const char *rest = line + 1; + size_t rl; + rest = xtrim_span(rest, (size_t)(line_end - rest), &rl); + if (rl > 0) + sl_push(out, xstrndup(rest, rl)); + } + p = nl ? nl + 1 : p + linelen; + } +} + +/* ------------------------------------------------------------------ */ +/* Compose history */ +/* ------------------------------------------------------------------ */ + +history *compact_compose_history(const char *summary, + const char **file_paths, + const char **file_contents, size_t nfiles, + const message *tail, size_t ntail) { + xbuf body; + xbuf_init(&body); + xbuf_put(&body, + "The earlier conversation was summarized to fit the context " + "window. Continue from this summary.\n\n"); + if (summary) { + size_t tlen; + const char *s = xtrim_span(summary, strlen(summary), &tlen); + xbuf_putn(&body, s, tlen); + xbuf_push(&body, '\n'); + } + + unsigned long total = 0; + size_t taken = 0; + for (size_t i = 0; i < nfiles && i < MAX_FILES; i++) { + if (total >= FILES_CAP) + break; + const char *content = file_contents[i] ? file_contents[i] : ""; + size_t cpoints = utf8_len(content); + unsigned long take = PER_FILE_CAP; + if (take > FILES_CAP - total) + take = FILES_CAP - total; + if (take > cpoints) + take = cpoints; + if (take == 0) + break; + size_t take_bytes = utf8_byte_len(content, take); + total += take; + taken++; + xbuf_fmt(&body, "\n### %s\n```\n", file_paths[i] ? file_paths[i] : ""); + xbuf_putn(&body, content, take_bytes); + xbuf_put(&body, "\n```\n"); + } + + history *h = (history *) xmalloc(sizeof(*h)); + new (h) history{}; + char *body_text = xbuf_steal(&body); + h->msgs.push_back(msg_user_text(body_text)); + free(body_text); + for (size_t i = 0; i < ntail; i++) + h->msgs.push_back(tail[i]); + (void)taken; + return h; +} + +/* ------------------------------------------------------------------ */ +/* SelfCompact-style rubric probe (arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +const char *compact_rubric_preamble(void) { + return "You are a careful judge for a terminal coding agent. You are " + "given the agent's trajectory and a search rubric of four yes/no " + "questions (C1, C2, C3, N1). Answer each question exactly in the " + "requested line format, with the evidence it asks for. Do not " + "summarize the trajectory, do not restate the questions, and add " + "no text beyond the four verdict lines."; +} + +const char *compact_rubric_prompt(void) { + return "You are about to decide whether to compress your conversation " + "history into a summary that REPLACES the full history above. " + "After compression, you continue from only [system, original " + "question, assistant summary, user continue]. Compression is " + "irreversible: anything not preserved in the summary is gone.\n" + "\n" + "Compression is safe ONLY when ALL FOUR of the following hold: " + "(C1) the trajectory has reached a closed unit (not mid-thought), " + "(C2) the essential information is reducible to 3-5 citable " + "facts without loss, (C3) something has progressed since the last " + "compression, (N1) you are NOT currently stuck in a way " + "summarization would mask. Answer C1, C2, C3, N1 honestly. Each " + "Y answer requires verbatim evidence quoted from the trajectory " + "above; answers without evidence default to N.\n" + "\n" + "C1 CLOSED-UNIT: The most recent assistant message is a closed " + "unit - a completed tool call whose result is now visible, or a " + "completed sub-analysis with a clear stopping point. It is NOT " + "mid-sentence reasoning (\"Let me now check...\", \"I should " + "next look at...\"), and not a half-formulated query. *If Y*, " + "quote the closing fragment of the last assistant message. *If " + "N*, quote the open fragment that shows the trajectory is " + "mid-thought.\n" + "C2 SUMMARIZABLE: You can write 3-5 essential facts (with " + "verbatim citations from the trajectory) that future-you needs " + "to continue after compression. Each fact must be a single " + "concrete statement: a name, date, URL, quoted claim, or " + "resolved sub-question. Answer N if the trajectory's value is " + "dispersed across many small inferences (e.g., a list of dead-" + "end queries needed to avoid retries, negative results that " + "constrain the hypothesis space) that would be lost without the " + "dispersal. *If Y*, list the 3-5 facts numbered, each with a " + "verbatim citation in quotes. *If N*, name in one sentence the " + "class of information that would be lost.\n" + "C3 PROGRESS: Since the most recent compression (or since the " + "start of the conversation if none), you have either obtained a " + "new concrete fact (name, date, URL, or quoted claim) OR " + "refined the sub-question being pursued. *If Y*, name the new " + "fact or refined sub-question. *If N*, state that you are " + "returning the same state you compressed from.\n" + "N1 STUCK: At least 3 of your last 4 tool calls returned no new " + "information (i.e., were duplicates or returned already-known " + "content). If you have made fewer than 4 tool calls total, " + "answer N. *If Y*, name 1 distinct strategy you have NOT yet " + "tried (different tool, different query type, different angle " + "on the question). *If N*, name one new fact obtained " + "recently.\n" + "\n" + "Output: exactly 4 lines, no preamble or trailing text:\n" + "C1: Y/N -- \n" + "C2: Y/N -- \n" + "C3: Y/N -- \n" + "N1: Y/N -- "; +} + +/* First line whose start matches `label` (case-insensitive, followed by + * whitespace or ':'), or NULL. */ +static const char *rubric_line(const char *answer, const char *label) { + size_t ll = strlen(label); + const char *p = answer; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len >= ll && strncasecmp(p, label, ll) == 0 && + (len == ll || p[ll] == ' ' || p[ll] == '\t' || p[ll] == ':')) + return p; + if (!nl) + break; + p = nl + 1; + } + return NULL; +} + +/* Verdict at `line` (just past the label). Sets *evidence to the + * remainder of the line after an optional `--` separator. Returns 1 = Y, + * 0 = N, -1 = no verdict token. A Y with empty evidence is returned as N + * (the paper's "answers without evidence default to N"). */ +static int rubric_verdict(const char *line, const char **evidence) { + while (*line == ' ' || *line == '\t') + line++; + if (*line == ':') { + line++; + while (*line == ' ' || *line == '\t') + line++; + } + char v = (char)toupper((unsigned char)*line); + if (v != 'Y' && v != 'N') + return -1; + int verdict = (v == 'Y'); + line++; + while (*line == ' ' || *line == '\t') + line++; + if (line[0] == '-' && line[1] == '-') + line += 2; + while (*line == ' ' || *line == '\t') + line++; + *evidence = line; + if (verdict) { + /* A Y needs real evidence on the same line (the paper's rule). */ + const char *p = line; + while (*p && *p != '\n') + p++; + if (p == line) + verdict = 0; + } + return verdict; +} + +int compact_rubric_parse(const char *answer, compact_rubric *out, + char **err) { + if (err) + *err = NULL; + if (!out) + return -1; + out->c1 = out->c2 = out->c3 = out->n1 = 0; + if (!answer || !*answer) { + if (err) + *err = xstrdup("rubric: empty verdict"); + return -1; + } + struct { + const char *label; + int *slot; + } items[4] = { + {"C1", &out->c1}, + {"C2", &out->c2}, + {"C3", &out->c3}, + {"N1", &out->n1}, + }; + for (int i = 0; i < 4; i++) { + const char *line = rubric_line(answer, items[i].label); + if (!line) { + if (err) + *err = xstrfmt("rubric: missing `%s` verdict line", + items[i].label); + return -1; + } + const char *ev = NULL; + int verdict = rubric_verdict(line + strlen(items[i].label), &ev); + if (verdict < 0) { + if (err) + *err = xstrfmt("rubric: malformed `%s` verdict line", + items[i].label); + return -1; + } + *items[i].slot = verdict; + } + return 0; +} + +bool compact_rubric_fire(const compact_rubric *r) { + return r && r->c1 && r->c2 && r->c3 && !r->n1; +} + +bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens, + unsigned long summaries_so_far, + unsigned long max_summaries, + unsigned long rounds_since_probe) { + if (iteration < RUBRIC_MIN_ITERATION) + return false; + if (prompt_tokens < RUBRIC_MIN_TOKENS) + return false; + if (max_summaries > 0 && summaries_so_far >= max_summaries) + return false; + if (rounds_since_probe < RUBRIC_MIN_ROUNDS_SINCE_PROBE) + return false; + return true; +} diff --git a/src/compact.h b/src/compact.h new file mode 100644 index 0000000..c34a17d --- /dev/null +++ b/src/compact.h @@ -0,0 +1,124 @@ +#ifndef LLSH_COMPACT_H +#define LLSH_COMPACT_H + +#include "message.h" +#include "util.h" + +/* Mirrors compact.rs: pure helpers for the two-tier context compaction. + * Side-effect free (no I/O) so it is unit-testable; the file reads and the + * model call live in the session. */ + +#define CLEARED_PLACEHOLDER \ + "[tool output cleared to save context; re-run the tool if you need it]" + +/* Re-injected file caps: per-file chars, number of files, total budget. */ +#define PER_FILE_CAP 50000UL +#define MAX_FILES 5UL +#define FILES_CAP 100000UL + +/* System preamble for the tool-less one-shot summarizer. */ +const char *compact_summarizer_preamble(void); + +/* The resolved compaction knobs, carried by a session. */ +typedef struct { + unsigned long context_limit; /* 0 disables compaction entirely */ + double compact_threshold; /* fraction; 0.0 disables the tier */ + double clear_threshold; /* fraction; 0.0 disables the tier */ +} compact_config; + +/* Populate from a config (see config.h). */ +void compact_config_from(unsigned long context_limit, double compact_threshold, + double clear_threshold, compact_config *out); + +/* Token budget at which the summarization tier fires. + * Returns true (and sets *out) when enabled, false when disabled. */ +bool compact_at(const compact_config *c, unsigned long *out); +bool clear_at(const compact_config *c, unsigned long *out); + +/* Which compaction action actually ran. */ +typedef enum { + TIER_CLEARED, /* tier 1: stale tool results replaced in place */ + TIER_SUMMARIZED, /* tier 2: prefix summarized, recent files re-injected */ + TIER_FALLBACK_CLEAR /* tier 2 requested but summarization failed */ +} compact_tier; + +/* What a compaction pass did, for a status line and tests. */ +typedef struct { + compact_tier tier; + unsigned long tokens_before; + unsigned long tokens_after; + size_t cleared_tool_results; /* 0 when a summarization ran */ + size_t files_reinjected; /* 0 unless a summarization ran */ +} compact_report; + +/* Rough token count: total bytes / 4 (div_ceil). */ +unsigned long compact_estimate_tokens(const message *msgs, size_t n); + +/* Index of the last pure-text user message, or n when there is none. */ +size_t compact_keep_boundary(const message *msgs, size_t n); + +/* Replace content of stale tool results (strictly before boundary) with the + * placeholder. Idempotent. Returns results cleared on this pass. */ +size_t compact_clear_old_tool_results(message *msgs, size_t n, size_t boundary); + +/* Flatten a message slice into a plain-text transcript. Caller frees. */ +char *compact_transcript(const message *msgs, size_t n); + +/* Render the conversation as a Markdown transcript. Caller frees. */ +char *compact_transcript_markdown(const message *msgs, size_t n); + +/* Extract file paths listed under a `## Files` heading (case-insensitive; + * accepts `-` or `*` bullets). Caller frees the strlist. */ +void compact_recent_file_paths(const char *summary, strlist *out); + +/* Build the post-compaction history: one user message carrying the summary + * and any re-injected files, followed by the kept tail (copied). + * Heap-allocated: caller frees with history_free(h); free(h); */ +history *compact_compose_history(const char *summary, + const char **file_paths, + const char **file_contents, size_t nfiles, + const message *tail, size_t ntail); + +/* ------------------------------------------------------------------ */ +/* SelfCompact-style rubric probe (arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +/* Fire-gate thresholds from the paper. max_summaries 0 = unlimited. */ +#define RUBRIC_MIN_ITERATION 3UL +#define RUBRIC_MIN_TOKENS 40000UL +#define RUBRIC_MIN_ROUNDS_SINCE_PROBE 2UL +#define RUBRIC_DEFAULT_MAX_SUMMARIES 1UL + +/* Verdict of a rubric probe. Each field: 1 = Y, 0 = N. */ +typedef struct { + int c1; /* CLOSED-UNIT: last assistant message is a closed unit */ + int c2; /* SUMMARIZABLE: value fits 3-5 citable facts */ + int c3; /* PROGRESS: something advanced since last compression */ + int n1; /* STUCK: recent tool calls yield nothing new */ +} compact_rubric; + +/* System preamble for the rubric judge (kept out of the summarizer's + * "write a summary" role). */ +const char *compact_rubric_preamble(void); + +/* The probe prompt (the paper's search rubric, adapted to tool calls). */ +const char *compact_rubric_prompt(void); + +/* Parse the four `C1/C2/C3/N1: Y|N -- ` lines. A Y without + * evidence on its line is downgraded to N (the paper's rule). `out` must + * be non-NULL; 0 on success, -1 with owned *err when a verdict is missing + * or malformed. */ +int compact_rubric_parse(const char *answer, compact_rubric *out, char **err); + +/* Fire only when C1 and C2 and C3 are all Y and N1 is N. */ +bool compact_rubric_fire(const compact_rubric *r); + +/* Gate check: iteration (tool rounds) >= 3, prompt >= 40k tokens, + * summaries so far < cap (0 = unlimited), and >= 2 tool rounds since the + * last probe. */ +bool compact_rubric_due(unsigned long iteration, unsigned long prompt_tokens, + unsigned long summaries_so_far, + unsigned long max_summaries, + unsigned long rounds_since_probe); + +#endif diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..4f3d00a --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,732 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "compact.h" +#include "dotagents.h" +#include "tools.h" + +#define DEFAULT_COMPACT_THRESHOLD 0.75 +#define DEFAULT_CLEAR_THRESHOLD 0.5 +#define DEFAULT_MAX_RETRIES 3UL +#define DEFAULT_SKILL_INDEX_CAP 50UL +#define DEFAULT_SHELL_TIMEOUT_SECS 120UL +#define DEFAULT_MAX_OUTPUT_CHARS 16000UL +#define DEFAULT_MAX_FILE_BYTES 2000000UL + +/* The default system preamble. The "Available tools" section is generated + * from the tool registry (tools.c) so it can't drift from the actual + * tool set. */ +static const char *PREAMBLE_HEAD = + "You are an agent operating inside a terminal. You accomplish tasks by " + "calling tools.\n" + "\n"; +static const char *PREAMBLE_TAIL = + "\n" + "Guidelines:\n" + "- Paths may be relative to the working directory or absolute.\n" + "- Inspect before you modify: use read_file, or run_shell with ls / " + "rg / grep, to understand existing state.\n" + "- Prefer small, verifiable steps. After making changes, verify them " + "(e.g. run the build or tests with run_shell).\n" + "- Keep run_shell commands safe and scoped to the task; avoid " + "destructive commands unless explicitly asked.\n" + "- When a task is complete, stop calling tools and reply with a short " + "summary of what you did."; + +char *default_preamble(void) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, PREAMBLE_HEAD); + char *tools = tool_preamble_list(); + xbuf_put(&b, tools); + free(tools); + xbuf_put(&b, PREAMBLE_TAIL); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +/* ------------------------------------------------------------------ */ +/* Providers */ +/* ------------------------------------------------------------------ */ + +provider_kind provider_parse(const char *raw, char **err) { + xbuf l; + xbuf_init(&l); + for (const char *p = raw; *p; p++) + xbuf_push(&l, (char)tolower((unsigned char)*p)); + l.data[l.len] = '\0'; + const char *s = l.data; + if (strcmp(s, "openai") == 0 || strcmp(s, "openai-compatible") == 0 || + strcmp(s, "openai_compatible") == 0) { + xbuf_free(&l); + return PROV_OPENAI; + } + if (strcmp(s, "anthropic") == 0 || strcmp(s, "claude") == 0) { + xbuf_free(&l); + return PROV_ANTHROPIC; + } + if (strcmp(s, "ollama") == 0) { + xbuf_free(&l); + return PROV_OLLAMA; + } + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "unknown provider `%s` (expected openai, anthropic, or " + "ollama)", + raw); + xbuf_free(&l); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return PROV_OPENAI; +} + +const char *provider_name(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "openai"; + case PROV_ANTHROPIC: + return "anthropic"; + case PROV_OLLAMA: + return "ollama"; + } + return "openai"; +} + +static const char *provider_debug(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "OpenAI"; + case PROV_ANTHROPIC: + return "Anthropic"; + case PROV_OLLAMA: + return "Ollama"; + } + return "OpenAI"; +} + +const char *provider_default_model(provider_kind k) { + switch (k) { + case PROV_OPENAI: + return "gpt-5.2"; + case PROV_ANTHROPIC: + return "claude-sonnet-4-6"; + case PROV_OLLAMA: + return "llama3.2"; + } + return "gpt-5.2"; +} + +/* ------------------------------------------------------------------ */ +/* CLI parsing */ +/* ------------------------------------------------------------------ */ + +static void print_usage(FILE *f) { + fprintf( + f, + "llsh %s\n" + "llsh — a terminal LLM agent harness built on rig\n" + "\n" + "Usage: llsh [OPTIONS] [PROMPT]\n" + "\n" + "Arguments:\n" + " [PROMPT] One-shot prompt. If omitted (and stdin is a TTY), " + "launches the interactive REPL.\n" + "\n" + "Options:\n" + " --model Model name, e.g. `gpt-5.2`, " + "`claude-sonnet-4-6`, `llama3.2`; or a `.agents/models.json` " + "preset name (which also brings that preset's " + "provider/base_url/api_key). Overrides the preset's `model`; " + "defaults per provider when unset\n" + " --provider Model provider: `openai`, " + "`anthropic`, or `ollama`. `openai` also works against any " + "OpenAI-compatible endpoint via `--base-url`\n" + " --base-url Base URL override. For `openai` " + "this targets any OpenAI-compatible endpoint (LM Studio, vLLM, " + "Ollama's /v1, llama.cpp, ...); for `ollama` this is the Ollama " + "server address (default http://localhost:11434)\n" + " --api-key API key. Overrides the preset's " + "`api_key`\n" + " -C, --cwd Working directory the agent's file and shell " + "tools operate in\n" + " --preamble System preamble for the agent\n" + " --max-turns Maximum model calls (including " + "tool-call continuations) per user turn. `0` (the default) is " + "unbounded\n" + " --max-retries Number of times a transient " + "provider failure (timeout, 429, 5xx) is retried with exponential " + "backoff before the turn fails. `0` disables retry\n" + " --context-limit Model context window in " + "tokens. `0` (the default) disables context compaction; a window " + "reported by the provider's endpoint is adopted when available, " + "otherwise this value is used\n" + " --compact-threshold Fraction of " + "`context_limit` at which earlier context is summarized. `0` " + "disables the tier; otherwise the value must be in `(0, 1]`\n" + " --clear-threshold Fraction of " + "`context_limit` at which stale tool results are cleared. `0` " + "disables the tier; otherwise the value must be in `(0, 1]`\n" + " --no-rubric-compaction Disable the SelfCompact rubric " + "probe that compacts the trajectory at a judged-safe point " + "(independent of the token thresholds above)\n" + " --rubric-max-summaries Cap on rubric-triggered " + "compactions per session. The default is `1` (the paper's cap); " + "`0` means unlimited\n" + " --verbose Verbose, structured logging to stderr (one JSON " + "object per event: turn lifecycle, tool calls, compaction, " + "retries, and errors)\n" + " --debug Write the whole conversation history to " + "`debug.json` (in the working directory) after every turn and " + "again on exit\n" + " --skill-index-cap Maximum number of " + "skills listed in the preamble's skills index\n" + " --shell-timeout-secs `run_shell` " + "timeout in seconds (the whole process tree is killed on expiry)\n" + " --max-output-chars Cap on tool/shell " + "output text, in characters (longer is truncated)\n" + " --max-file-bytes Cap on the size of a " + "file `read_file` will read, in bytes\n" + " --resume Resume a session saved by `/save`: restore " + "its conversation, model, preset, sub-agent, and working " + "directory. The saved model/config values are authoritative, so " + "`--model` / `--provider` / `--base-url` / `--api-key` are " + "ignored when this is set\n" + " --allow-mcp Trust and start MCP servers from .agents/mcp.json\n" + " --shell-allow Allow this run_shell command (repeatable)\n" + " --shell-deny Deny this run_shell command (repeatable)\n" + " --confirm-destructive Prompt before recognized destructive shell commands\n" + " --no-confirm-destructive Disable the destructive-command prompt\n" + " -h, --help Print help\n" + " -V, --version Print version\n", + LLSH_VERSION); +} + +static int parse_ulong(const char *s, unsigned long *out, char **err, + const char *flag) { + /* strtoull silently accepts a leading '-' and wraps on overflow; reject + * both explicitly so `--max-turns -1` does not become ULONG_MAX. */ + errno = 0; + char *endp = NULL; + unsigned long v = strtoull(s, &endp, 10); + if (s[0] == '-' || endp == s || *endp != '\0' || errno == ERANGE) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "invalid value for %s: `%s`", flag, s); + *err = xbuf_steal(&b); + } + return -1; + } + *out = v; + return 0; +} + +static int parse_fraction_arg(const char *s, double *out, char **err, + const char *flag) { + errno = 0; + char *endp = NULL; + double value = strtod(s, &endp); + if (endp == s || *endp != '\0' || errno == ERANGE || !isfinite(value) || + value < 0.0 || value > 1.0) { + if (err) + *err = xstrfmt("invalid value for %s: `%s` (expected 0 or a finite value in (0, 1])", + flag, s); + return -1; + } + *out = value; + return 0; +} + +int cli_parse(int argc, char **argv, cli *out, char **err) { + memset(out, 0, sizeof *out); + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + + enum { + OPT_MODEL = 256, + OPT_PROVIDER, + OPT_BASE_URL, + OPT_API_KEY, + OPT_CWD, + OPT_PREAMBLE, + OPT_MAX_TURNS, + OPT_MAX_RETRIES, + OPT_CONTEXT_LIMIT, + OPT_COMPACT_THRESHOLD, + OPT_CLEAR_THRESHOLD, + OPT_VERBOSE, + OPT_DEBUG, + OPT_NO_RUBRIC, + OPT_RUBRIC_MAX, + OPT_SKILL_INDEX_CAP, + OPT_SHELL_TIMEOUT, + OPT_MAX_OUTPUT, + OPT_MAX_FILE, + OPT_RESUME, + OPT_ALLOW_MCP, + OPT_SHELL_ALLOW, + OPT_SHELL_DENY, + OPT_CONFIRM_DESTRUCTIVE, + OPT_NO_CONFIRM_DESTRUCTIVE, + }; + static const struct option long_opts[] = { + {"model", required_argument, 0, OPT_MODEL}, + {"provider", required_argument, 0, OPT_PROVIDER}, + {"base-url", required_argument, 0, OPT_BASE_URL}, + {"api-key", required_argument, 0, OPT_API_KEY}, + {"cwd", required_argument, 0, OPT_CWD}, + {"preamble", required_argument, 0, OPT_PREAMBLE}, + {"max-turns", required_argument, 0, OPT_MAX_TURNS}, + {"max-retries", required_argument, 0, OPT_MAX_RETRIES}, + {"context-limit", required_argument, 0, OPT_CONTEXT_LIMIT}, + {"compact-threshold", required_argument, 0, OPT_COMPACT_THRESHOLD}, + {"clear-threshold", required_argument, 0, OPT_CLEAR_THRESHOLD}, + {"verbose", no_argument, 0, OPT_VERBOSE}, + {"debug", no_argument, 0, OPT_DEBUG}, + {"no-rubric-compaction", no_argument, 0, OPT_NO_RUBRIC}, + {"rubric-max-summaries", required_argument, 0, OPT_RUBRIC_MAX}, + {"skill-index-cap", required_argument, 0, OPT_SKILL_INDEX_CAP}, + {"shell-timeout-secs", required_argument, 0, OPT_SHELL_TIMEOUT}, + {"max-output-chars", required_argument, 0, OPT_MAX_OUTPUT}, + {"max-file-bytes", required_argument, 0, OPT_MAX_FILE}, + {"resume", required_argument, 0, OPT_RESUME}, + {"allow-mcp", no_argument, 0, OPT_ALLOW_MCP}, + {"shell-allow", required_argument, 0, OPT_SHELL_ALLOW}, + {"shell-deny", required_argument, 0, OPT_SHELL_DENY}, + {"confirm-destructive", no_argument, 0, OPT_CONFIRM_DESTRUCTIVE}, + {"no-confirm-destructive", no_argument, 0, OPT_NO_CONFIRM_DESTRUCTIVE}, + {"help", no_argument, 0, 'h'}, + {"version", no_argument, 0, 'V'}, + {0, 0, 0, 0}, + }; + + int c; + int prompt_set = 0; + optind = 1; + while ((c = getopt_long(argc, argv, "C:hV", long_opts, NULL)) != -1) { + switch (c) { + case OPT_MODEL: + out->model = xstrdup(optarg); + break; + case OPT_PROVIDER: + out->provider = xstrdup(optarg); + break; + case OPT_BASE_URL: + out->base_url = xstrdup(optarg); + break; + case OPT_API_KEY: + out->api_key = xstrdup(optarg); + break; + case OPT_CWD: + case 'C': + out->cwd = xstrdup(optarg); + break; + case OPT_PREAMBLE: + out->preamble = xstrdup(optarg); + break; + case OPT_MAX_TURNS: + if (parse_ulong(optarg, &out->max_turns, err, "--max-turns") != 0) + return -1; + out->has_max_turns = true; + break; + case OPT_MAX_RETRIES: + if (parse_ulong(optarg, &out->max_retries, err, + "--max-retries") != 0) + return -1; + out->has_max_retries = true; + break; + case OPT_CONTEXT_LIMIT: + if (parse_ulong(optarg, &out->context_limit, err, + "--context-limit") != 0) + return -1; + out->has_context_limit = true; + break; + case OPT_COMPACT_THRESHOLD: + if (parse_fraction_arg(optarg, &out->compact_threshold, err, + "--compact-threshold") != 0) + return -1; + out->has_compact_threshold = true; + break; + case OPT_CLEAR_THRESHOLD: + if (parse_fraction_arg(optarg, &out->clear_threshold, err, + "--clear-threshold") != 0) + return -1; + out->has_clear_threshold = true; + break; + case OPT_VERBOSE: + out->verbose = true; + break; + case OPT_DEBUG: + out->debug = true; + break; + case OPT_NO_RUBRIC: + out->no_rubric = true; + break; + case OPT_RUBRIC_MAX: + if (parse_ulong(optarg, &out->rubric_max_summaries, err, + "--rubric-max-summaries") != 0) + return -1; + out->has_rubric_max_summaries = true; + break; + case OPT_SKILL_INDEX_CAP: + if (parse_ulong(optarg, &out->skill_index_cap, err, + "--skill-index-cap") != 0) + return -1; + out->has_skill_index_cap = true; + break; + case OPT_SHELL_TIMEOUT: + if (parse_ulong(optarg, &out->shell_timeout_secs, err, + "--shell-timeout-secs") != 0) + return -1; + out->has_shell_timeout_secs = true; + break; + case OPT_MAX_OUTPUT: + if (parse_ulong(optarg, &out->max_output_chars, err, + "--max-output-chars") != 0) + return -1; + out->has_max_output_chars = true; + break; + case OPT_MAX_FILE: + if (parse_ulong(optarg, &out->max_file_bytes, err, + "--max-file-bytes") != 0) + return -1; + out->has_max_file_bytes = true; + break; + case OPT_RESUME: + out->resume = xstrdup(optarg); + break; + case OPT_ALLOW_MCP: + out->allow_mcp = true; + break; + case OPT_SHELL_ALLOW: + if (!optarg[0]) { + if (err) + *err = xstrdup("--shell-allow requires a non-empty command"); + return -1; + } + sl_push_copy(&out->shell_allowlist, optarg); + break; + case OPT_SHELL_DENY: + if (!optarg[0]) { + if (err) + *err = xstrdup("--shell-deny requires a non-empty command"); + return -1; + } + sl_push_copy(&out->shell_denylist, optarg); + break; + case OPT_CONFIRM_DESTRUCTIVE: + out->confirm_destructive = true; + out->has_confirm_destructive = true; + break; + case OPT_NO_CONFIRM_DESTRUCTIVE: + out->confirm_destructive = false; + out->has_confirm_destructive = true; + break; + case 'h': + print_usage(stdout); + return CLI_HELP; + case 'V': + fprintf(stdout, "llsh %s\n", LLSH_VERSION); + return CLI_HELP; + case '?': + print_usage(stderr); + if (err) + *err = xstrdup("try --help"); + return -1; + default: + /* getopt_long returns '?' for unknown options; anything else + * here is a short option we did not register. */ + print_usage(stderr); + if (err) + *err = xstrdup("try --help"); + return -1; + } + } + for (int i = optind; i < argc; i++) { + if (prompt_set) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "unexpected argument `%s`", argv[i]); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return -1; + } + out->prompt = xstrdup(argv[i]); + prompt_set = 1; + } + return 0; +} + +void cli_free(cli *c) { + free(c->prompt); + free(c->model); + free(c->provider); + free(c->base_url); + free(c->api_key); + free(c->cwd); + free(c->preamble); + free(c->resume); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + + +/* ------------------------------------------------------------------ */ +/* Merge + validation */ +/* ------------------------------------------------------------------ */ + +static int validate_fraction(const char *name, double value, char **err) { + if (value == 0.0) + return 0; + if (isnan(value) || isinf(value) || value <= 0.0 || value > 1.0) { + if (err) { + xbuf b; + xbuf_init(&b); + const char *rep; + if (isnan(value)) + rep = "NaN"; + else if (value > 0) + rep = "inf"; + else + rep = "-inf"; + xbuf_fmt(&b, "%s must be 0 or in (0, 1], got %s", name, rep); + *err = xbuf_steal(&b); + } + return -1; + } + return 0; +} + +static int validate_compaction(const config *c, char **err) { + if (validate_fraction("compact_threshold", c->compact_threshold, err) != 0) + return -1; + if (validate_fraction("clear_threshold", c->clear_threshold, err) != 0) + return -1; + if (c->context_limit > 0 && c->compact_threshold > 0.0 && + c->clear_threshold > 0.0 && + c->clear_threshold >= c->compact_threshold) { + if (err) { + char *ct = dtoa_dup(c->clear_threshold); + char *pt = dtoa_dup(c->compact_threshold); + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "clear_threshold (%s) must be less than " + "compact_threshold (%s)", + ct, pt); + *err = xbuf_steal(&b); + free(ct); + free(pt); + } + return -1; + } + return 0; +} + +int config_require_key_or_base(const config *c, char **err) { + if (c->provider != PROV_OLLAMA && !c->base_url && !c->api_key) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "no API key for provider `%s`: set `api_key` in " + "`.agents/models.json` or pass --api-key", + provider_debug(c->provider)); + *err = xbuf_steal(&b); + } + return -1; + } + return 0; +} + +void config_free(config *c) { + free(c->model); + free(c->base_url); + free(c->api_key); + free(c->preamble); + sl_free(&c->shell_allowlist); + sl_free(&c->shell_denylist); +} + +int config_merge(const cli *cli, config *out, char **err) { + memset(out, 0, sizeof *out); + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + out->confirm_destructive = cli->confirm_destructive; + for (size_t i = 0; i < cli->shell_allowlist.len; i++) + sl_push_copy(&out->shell_allowlist, cli->shell_allowlist.items[i]); + for (size_t i = 0; i < cli->shell_denylist.len; i++) + sl_push_copy(&out->shell_denylist, cli->shell_denylist.items[i]); + + out->provider = provider_parse(cli->provider ? cli->provider : "openai", + err); + if (err && *err) + return -1; + + out->model = xstrdup(cli->model ? cli->model + : provider_default_model(out->provider)); + + out->base_url = cli->base_url ? xstrdup(cli->base_url) : NULL; + out->api_key = cli->api_key ? xstrdup(cli->api_key) : NULL; + + out->preamble = cli->preamble ? xstrdup(cli->preamble) + : default_preamble(); + + out->max_turns = cli->has_max_turns ? cli->max_turns : 0; + out->max_retries = cli->has_max_retries ? cli->max_retries + : DEFAULT_MAX_RETRIES; + out->context_limit = + cli->has_context_limit ? cli->context_limit : 0; + out->compact_threshold = cli->has_compact_threshold ? cli->compact_threshold + : DEFAULT_COMPACT_THRESHOLD; + out->clear_threshold = cli->has_clear_threshold ? cli->clear_threshold + : DEFAULT_CLEAR_THRESHOLD; + + out->rubric_enabled = !cli->no_rubric; + out->rubric_max_summaries = cli->has_rubric_max_summaries + ? cli->rubric_max_summaries + : RUBRIC_DEFAULT_MAX_SUMMARIES; + + out->verbose = cli->verbose; + + out->skill_index_cap = + cli->has_skill_index_cap ? cli->skill_index_cap + : DEFAULT_SKILL_INDEX_CAP; + if (out->skill_index_cap < 1) + out->skill_index_cap = 1; + out->shell_timeout_secs = + cli->has_shell_timeout_secs ? cli->shell_timeout_secs + : DEFAULT_SHELL_TIMEOUT_SECS; + if (out->shell_timeout_secs < 1) + out->shell_timeout_secs = 1; + out->max_output_chars = + cli->has_max_output_chars ? cli->max_output_chars + : DEFAULT_MAX_OUTPUT_CHARS; + if (out->max_output_chars < 1) + out->max_output_chars = 1; + out->max_file_bytes = + cli->has_max_file_bytes ? cli->max_file_bytes + : DEFAULT_MAX_FILE_BYTES; + if (out->max_file_bytes < 1) + out->max_file_bytes = 1; + + if (validate_compaction(out, err) != 0) + return -1; + /* The api_key/base_url requirement is checked by the caller AFTER a + * models.json preset may have supplied them (precedence: CLI flag > + * models.json preset > provider defaults). */ + return 0; +} + +int config_from_cli(const cli *cli, config *out, char **err) { + const char *cwd = cli->cwd ? cli->cwd : "."; + + if (config_merge(cli, out, err) != 0) { + config_free(out); + return -1; + } + + char *cwd_exp = expand_tilde(cwd); + if (chdir(cwd_exp) != 0) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "failed to enter working directory `%s`: %s", + cwd_exp, strerror(errno)); + *err = xbuf_steal(&b); + } + free(cwd_exp); + config_free(out); + return -1; + } + free(cwd_exp); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* models.json presets */ +/* ------------------------------------------------------------------ */ + +char *config_apply_models_preset(config *cfg, const models_file *models, + const cli *cli, char **err) { + const char *name = models->default_name; + if (!name || !*name) { + if (err) + *err = xstrdup("models.json declares no `default` preset"); + return NULL; + } + const model_preset *preset = models_file_get(models, name); + if (!preset) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "models.json default preset `"); + xbuf_put(&b, name); + xbuf_put(&b, "` is not in presets ("); + for (size_t i = 0; i < models->preset_count; i++) { + if (i) + xbuf_put(&b, ", "); + xbuf_put(&b, models->presets[i].name ? models->presets[i].name : ""); + } + xbuf_put(&b, ")"); + *err = xbuf_steal(&b); + } + return NULL; + } + if (!cli->provider && preset->provider) { + cfg->provider = provider_parse(preset->provider, err); + if (err && *err) + return NULL; + } + if (!cli->model && preset->model) { + free(cfg->model); + cfg->model = xstrdup(preset->model); + } + if (!cli->base_url && preset->base_url) { + free(cfg->base_url); + cfg->base_url = xstrdup(preset->base_url); + } + if (!cli->api_key && preset->api_key) { + free(cfg->api_key); + cfg->api_key = xstrdup(preset->api_key); + } + if (!cli->has_max_turns && preset->has_max_turns && + preset->max_turns > 0) + cfg->max_turns = preset->max_turns; + if (!cli->has_context_limit && preset->has_context_limit) + cfg->context_limit = preset->context_limit; + + if (cfg->provider != PROV_OLLAMA && !cfg->base_url && !cfg->api_key) { + if (err) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, + "no API key for provider `%s` after applying preset " + "`%s`: set `api_key` in the preset/config file or " + "pass --api-key", + provider_debug(cfg->provider), name); + *err = xbuf_steal(&b); + } + return NULL; + } + if (validate_compaction(cfg, err) != 0) + return NULL; + return xstrdup(name); +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..d71a78b --- /dev/null +++ b/src/config.h @@ -0,0 +1,106 @@ +#ifndef LLSH_CONFIG_H +#define LLSH_CONFIG_H + +#include "dotagents.h" +#include "util.h" + +/* Mirrors config.rs: CLI + file config + merged Config. */ + +typedef enum { + PROV_OPENAI, + PROV_ANTHROPIC, + PROV_OLLAMA, +} provider_kind; + +#define LLSH_VERSION "0.1.0" + +typedef struct { + char *prompt; + char *model; + char *provider; + char *base_url; + char *api_key; + char *cwd; + char *preamble; + unsigned long max_turns; + bool has_max_turns; + unsigned long max_retries; + bool has_max_retries; + unsigned long context_limit; + bool has_context_limit; + double compact_threshold; + bool has_compact_threshold; + double clear_threshold; + bool has_clear_threshold; + bool verbose; + bool allow_mcp; + bool confirm_destructive; + bool has_confirm_destructive; + strlist shell_allowlist; + strlist shell_denylist; + bool debug; + bool no_rubric; /* --no-rubric-compaction */ + unsigned long rubric_max_summaries; + bool has_rubric_max_summaries; + unsigned long skill_index_cap; + bool has_skill_index_cap; + unsigned long shell_timeout_secs; + bool has_shell_timeout_secs; + unsigned long max_output_chars; + bool has_max_output_chars; + unsigned long max_file_bytes; + bool has_max_file_bytes; + char *resume; +} cli; + +typedef struct { + provider_kind provider; + char *model; + char *base_url; /* may be NULL */ + char *api_key; /* may be NULL */ + char *preamble; + unsigned long max_turns; + unsigned long max_retries; + unsigned long context_limit; + double compact_threshold; + double clear_threshold; + bool rubric_enabled; + unsigned long rubric_max_summaries; /* 0 = unlimited */ + bool verbose; + unsigned long skill_index_cap; + unsigned long shell_timeout_secs; + unsigned long max_output_chars; + unsigned long max_file_bytes; + strlist shell_allowlist; + strlist shell_denylist; + bool confirm_destructive; +} config; + +/* Parse argv (without program name). 0 on success; -2 prints help and returns + * CLI_HELP (call should exit); -1 error. */ +#define CLI_HELP (-2) +int cli_parse(int argc, char **argv, cli *out, char **err); +void cli_free(cli *c); + +/* Merge CLI + file with precedence CLI > file > defaults. Changes CWD. */ +/* Build the merged config directly from CLI arguments (the former + * config-file layer was removed; connection presets live in + * `.agents/models.json`). */ +int config_from_cli(const cli *cli, config *out, char **err); +int config_merge(const cli *cli, config *out, char **err); +void config_free(config *c); + +/* Reject key-requiring providers with neither api_key nor base_url. Run + * after a models.json preset may have supplied them (the merge itself no + * longer checks). */ +int config_require_key_or_base(const config *c, char **err); +provider_kind provider_parse(const char *raw, char **err); +const char *provider_name(provider_kind k); +const char *provider_default_model(provider_kind k); + +/* Apply a models.json `default` preset (see dotagents). Returns preset name + * (caller frees) or NULL + *err. */ +char *config_apply_models_preset(config *cfg, const models_file *models, + const cli *cli, char **err); + +#endif diff --git a/src/escape.c b/src/escape.c new file mode 100644 index 0000000..de6e1bd --- /dev/null +++ b/src/escape.c @@ -0,0 +1,5 @@ +/* escape.c — translation unit for the escape.h ANSI escape library. + * escape.h is a hardlink into ../header-libraries; the macro interface is + * header-only, this file provides the esc_* functions. */ +#define ESCAPE_IMPLEMENTATION +#include "escape.h" diff --git a/src/goal.cpp b/src/goal.cpp new file mode 100644 index 0000000..36aa271 --- /dev/null +++ b/src/goal.cpp @@ -0,0 +1,241 @@ +#include "goal.h" + +#include +#include +#include + +static const char *const GOAL_PREAMBLE = + "You are the reviewer for a terminal coding agent that is working toward a " + "goal. You are given the goal — a success condition — and the agent's most " + "recent reply. Decide whether the goal is fully met.\n" + "\n" + "Reply with exactly two lines:\n" + "1. Either `MET` or `NOT MET` — your verdict.\n" + "2. One short sentence: if met, what was accomplished; if not, the single " + "most important gap to fix next.\n" + "\n" + "Be strict: the goal is met only when the stated success condition is " + "clearly satisfied — not merely started, in progress, or partially done. " + "Do not give the agent the benefit of the doubt, and do not add commentary " + "beyond the two lines."; + +void goal_spec_free(goal_spec *s) +{ + if (!s) + return; + free(s->criteria); + s->criteria = NULL; +} + +void goal_report_free(goal_report *r) +{ + if (!r) + return; + free(r->criteria); + free(r); +} + +const char *goal_evaluator_preamble(void) +{ + return GOAL_PREAMBLE; +} + +static bool str_eq_i(const char *s, size_t n, const char *word) +{ + size_t wn = strlen(word); + if (n != wn) + return false; + for (size_t i = 0; i < wn; i++) { + char a = s[i], b = word[i]; + if (a >= 'a' && a <= 'z') + a = (char)(a - 'a' + 'A'); + if (b >= 'a' && b <= 'z') + b = (char)(b - 'a' + 'A'); + if (a != b) + return false; + } + return true; +} + +static int parse_tries(const char *s, size_t n, unsigned long *out, char **err) +{ + size_t tlen; + const char *t = xtrim_span(s, n, &tlen); + size_t digits = 0; + while (digits < tlen && t[digits] >= '0' && t[digits] <= '9') + digits++; + if (digits == 0) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + /* After the digits: end of input, or the word `tries` as a whole word. + * "5xyz" is rejected; "5 tries" is the canonical form. */ + size_t rest = digits; + while (rest < tlen && isspace((unsigned char)t[rest])) + rest++; + if (rest != tlen) { + size_t wn = 5; + bool word_ok = (tlen - rest) >= wn; + if (word_ok) { + const char *w = "tries"; + for (size_t i = 0; i < wn; i++) { + char a = t[rest + i], b = w[i]; + if (a >= 'a' && a <= 'z') + a = (char)(a - 'a' + 'A'); + if (b >= 'a' && b <= 'z') + b = (char)(b - 'a' + 'A'); + if (a != b) { + word_ok = false; + break; + } + } + } + if (!word_ok || ((tlen - rest) > wn && + isalpha((unsigned char)t[rest + wn]))) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + } + unsigned long value = 0; + for (size_t i = 0; i < digits; i++) { + value = value * 10UL + (unsigned long)(t[i] - '0'); + if (value > 4294967295UL) { + *err = xstrdup( + "expected a number of tries after `stop after`, e.g. " + "`stop after 5 tries`"); + return 0; + } + } + if (value == 0) { + *err = xstrdup("the goal needs at least 1 try"); + return 0; + } + *out = value; + return 1; +} + +int goal_parse(const char *input, goal_spec *out, char **err) +{ + /* Zero on entry so a failed parse leaves a freeable, empty spec. */ + memset(out, 0, sizeof *out); + const char *src = input ? input : ""; + size_t slen = strlen(src); + size_t tlen; + const char *t = xtrim_span(src, slen, &tlen); + if (tlen == 0) { + *err = xstrdup( + "give the goal its success criteria, e.g. " + "`/goal all tests pass, stop after 5 tries`"); + return 0; + } + + const char marker[] = "stop after"; + size_t mlen = sizeof(marker) - 1; + /* rfind: last occurrence of marker in [t, t+tlen). */ + const char *hit = NULL; + const char *p = t; + while (tlen - (size_t)(p - t) >= mlen) { + void *m = memmem(p, tlen - (size_t)(p - t), marker, mlen); + if (!m) + break; + hit = (const char* ) m; + p = (const char *)m + 1; + } + + if (!hit) { + out->criteria = xstrndup(t, tlen); + out->max_tries = GOAL_DEFAULT_MAX_TRIES; + return 1; + } + + size_t pre_len = (size_t)(hit - t); + size_t clen; + const char *crit = xtrim_span(t, pre_len, &clen); + while (clen > 0 && crit[clen - 1] == ',') + clen--; + crit = xtrim_span(crit, clen, &clen); + if (clen == 0) { + *err = xstrdup( + "give the goal its success criteria before `stop after`, e.g. " + "`/goal all tests pass, stop after 5 tries`"); + return 0; + } + const char *tail = hit + mlen; + if (!parse_tries(tail, tlen - (size_t)(tail - t), &out->max_tries, err)) + return 0; + out->criteria = xstrndup(crit, clen); + return 1; +} + +char *goal_prompt(const goal_spec *spec, unsigned long attempt, + const char *feedback) +{ + const char *crit = spec->criteria ? spec->criteria : ""; + if (attempt <= 1) + return xstrfmt( + "Work toward this goal until it is fully met:\n\n%s\n\n" + "Judge your own progress, verify it, and keep going if it is not " + "done. When you are confident the goal is fully met, stop and say " + "what you did.", + crit); + const char *fb = feedback ? feedback : "no specific feedback was given"; + return xstrfmt( + "You are on attempt %lu of %lu toward this goal:\n\n%s\n\n" + "A reviewer judged the goal NOT yet met: %s\n\n" + "Address that gap and keep working. When you are confident the goal is " + "fully met, stop and say what you did.", + attempt, spec->max_tries, crit, fb); +} + +/* True if `needle` occurs in `hay` as a whole word (A-Z only; `hay` is + * already upper-cased). Prevents "MET" matching inside "UNMET". */ +static bool has_word(const char *hay, const char *needle) { + size_t nl = strlen(needle); + const char *p = hay; + while ((p = strstr(p, needle)) != NULL) { + bool left_ok = (p == hay) || (p[-1] < 'A' || p[-1] > 'Z'); + bool right_ok = (p[nl] < 'A' || p[nl] > 'Z'); + if (left_ok && right_ok) + return true; + p += (nl > 0 ? nl : 1); + } + return false; +} + +void goal_parse_verdict(const char *reply, goal_verdict *out) +{ + const char *src = reply ? reply : ""; + char *upper = xstrdup(src); + for (char *c = upper; *c; c++) + if (*c >= 'a' && *c <= 'z') + *c = (char)(*c - 'a' + 'A'); + out->met = has_word(upper, "MET") && !has_word(upper, "NOT MET") && + !has_word(upper, "UNMET"); + free(upper); + + const char *chosen = NULL; + size_t chosen_len = 0; + const char *p = src; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 0 && !str_eq_i(line, tlen, "MET") && + !str_eq_i(line, tlen, "NOT MET")) { + chosen = line; + chosen_len = tlen; + break; + } + if (!nl) + break; + p = nl + 1; + } + out->reason = chosen + ? xstrndup(chosen, chosen_len) + : xstrdup("the reviewer did not give a reason"); +} diff --git a/src/goal.h b/src/goal.h new file mode 100644 index 0000000..6ef823d --- /dev/null +++ b/src/goal.h @@ -0,0 +1,53 @@ +#ifndef LLSH_GOAL_H +#define LLSH_GOAL_H + +#include "util.h" + +/* Pure helpers for the /goal goal-based loop (mirrors goal.rs): parse the + * command, build each attempt's prompt, parse the evaluator's verdict. + * Side-effect free; the evaluator's model call lives in the session and the + * iteration loop in main. */ + +#define GOAL_DEFAULT_MAX_TRIES 5UL + +/* Parsed /goal command: success criteria + attempt cap. */ +typedef struct { + char *criteria; /* owned */ + unsigned long max_tries; +} goal_spec; + +/* Evaluator's judgment of a single attempt. reason is owned. */ +typedef struct { + bool met; + char *reason; +} goal_verdict; + +/* Summary of a finished /goal run. criteria is owned. */ +typedef struct { + char *criteria; + unsigned long max_tries; + unsigned long turns_done; + unsigned long long tokens_used; + bool met; +} goal_report; + +void goal_spec_free(goal_spec *s); +void goal_report_free(goal_report *r); + +/* System preamble for the tool-less goal evaluator. */ +const char *goal_evaluator_preamble(void); + +/* Parse "[, stop after tries]". + * Returns 1 on success (fills *out), 0 on failure (*err set, caller frees). */ +int goal_parse(const char *input, goal_spec *out, char **err); + +/* Prompt for a goal attempt (attempt starts at 1; feedback may be NULL). + * Caller frees. */ +char *goal_prompt(const goal_spec *spec, unsigned long attempt, + const char *feedback); + +/* Parse the evaluator's reply into a verdict (conservative: met only when + * MET appears and NOT MET does not). Caller frees out->reason. */ +void goal_parse_verdict(const char *reply, goal_verdict *out); + +#endif diff --git a/src/input.cpp b/src/input.cpp new file mode 100644 index 0000000..935f998 --- /dev/null +++ b/src/input.cpp @@ -0,0 +1,1949 @@ +#include "input.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include + +/* ================================================================== */ +/* Actions / events */ +/* ================================================================== */ + +void input_action_free(input_action *a) { + if (!a) return; + free(a->text); + a->text = NULL; + a->kind = ACT_NONE; +} + +void evlist_init(evlist *l) { + memset(l, 0, sizeof(*l)); +} + +void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len) { + if (l->len == l->cap) { + size_t new_cap = l->cap ? l->cap * 2 : 8; + input_key *ni = (input_key *) xmalloc(new_cap * sizeof(*ni)); + unsigned char* *nr = (unsigned char* * ) xmalloc(new_cap * sizeof(*nr)); + size_t *nl = (size_t *) xmalloc(new_cap * sizeof(*nl)); + if (l->cap > 0) { + for (size_t i = 0; i < l->len; i++) { + size_t src = (l->head + i) % l->cap; + ni[i] = l->items[src]; + nr[i] = l->raws[src]; + nl[i] = l->lens[src]; + } + free(l->items); + free(l->raws); + free(l->lens); + } + l->items = ni; + l->raws = nr; + l->lens = nl; + l->head = 0; + l->cap = new_cap; + } + size_t i = (l->head + l->len) % l->cap; + l->items[i] = k; + l->raws[i] = (unsigned char* ) xmalloc(raw_len + 1); + memcpy(l->raws[i], raw, raw_len); + l->raws[i][raw_len] = '\0'; + l->lens[i] = raw_len; + l->len++; +} + +void evlist_pop(evlist *l) { + if (l->len == 0) return; + free(l->raws[l->head]); + l->head = (l->head + 1 == l->cap) ? 0 : l->head + 1; + l->len--; +} + +void evlist_free(evlist *l) { + if (l->cap > 0) + for (size_t i = 0; i < l->len; i++) + free(l->raws[(l->head + i) % l->cap]); + free(l->items); + free(l->raws); + free(l->lens); + memset(l, 0, sizeof(*l)); +} + +/* ================================================================== */ +/* Display width */ +/* ================================================================== */ + +/* Unicode width tables generated from `unicode-width` 0.2 (the oracle's + * source of truth), covering U+0000..U+2FFFF. Zero-width (control chars, + * combining marks) and wide/fullwidth codepoint ranges. */ +static const uint32_t zw[][2] = { + {0x0u, 0x1fu}, + {0x7fu, 0x9fu}, + {0xadu, 0xadu}, + {0x300u, 0x36fu}, + {0x483u, 0x489u}, + {0x591u, 0x5bdu}, + {0x5bfu, 0x5bfu}, + {0x5c1u, 0x5c2u}, + {0x5c4u, 0x5c5u}, + {0x5c7u, 0x5c7u}, + {0x605u, 0x605u}, + {0x610u, 0x61au}, + {0x61cu, 0x61cu}, + {0x64bu, 0x65fu}, + {0x670u, 0x670u}, + {0x6d6u, 0x6dcu}, + {0x6dfu, 0x6e4u}, + {0x6e7u, 0x6e8u}, + {0x6eau, 0x6edu}, + {0x70fu, 0x70fu}, + {0x711u, 0x711u}, + {0x730u, 0x74au}, + {0x7a6u, 0x7b0u}, + {0x7ebu, 0x7f3u}, + {0x7fdu, 0x7fdu}, + {0x816u, 0x819u}, + {0x81bu, 0x823u}, + {0x825u, 0x827u}, + {0x829u, 0x82du}, + {0x859u, 0x85bu}, + {0x890u, 0x891u}, + {0x897u, 0x89fu}, + {0x8cau, 0x902u}, + {0x93au, 0x93au}, + {0x93cu, 0x93cu}, + {0x941u, 0x948u}, + {0x94du, 0x94du}, + {0x951u, 0x957u}, + {0x962u, 0x963u}, + {0x981u, 0x981u}, + {0x9bcu, 0x9bcu}, + {0x9beu, 0x9beu}, + {0x9c1u, 0x9c4u}, + {0x9cdu, 0x9cdu}, + {0x9d7u, 0x9d7u}, + {0x9e2u, 0x9e3u}, + {0x9feu, 0x9feu}, + {0xa01u, 0xa02u}, + {0xa3cu, 0xa3cu}, + {0xa41u, 0xa42u}, + {0xa47u, 0xa48u}, + {0xa4bu, 0xa4du}, + {0xa51u, 0xa51u}, + {0xa70u, 0xa71u}, + {0xa75u, 0xa75u}, + {0xa81u, 0xa82u}, + {0xabcu, 0xabcu}, + {0xac1u, 0xac5u}, + {0xac7u, 0xac8u}, + {0xacdu, 0xacdu}, + {0xae2u, 0xae3u}, + {0xafau, 0xaffu}, + {0xb01u, 0xb01u}, + {0xb3cu, 0xb3cu}, + {0xb3eu, 0xb3fu}, + {0xb41u, 0xb44u}, + {0xb4du, 0xb4du}, + {0xb55u, 0xb57u}, + {0xb62u, 0xb63u}, + {0xb82u, 0xb82u}, + {0xbbeu, 0xbbeu}, + {0xbc0u, 0xbc0u}, + {0xbcdu, 0xbcdu}, + {0xbd7u, 0xbd7u}, + {0xc00u, 0xc00u}, + {0xc04u, 0xc04u}, + {0xc3cu, 0xc3cu}, + {0xc3eu, 0xc40u}, + {0xc46u, 0xc48u}, + {0xc4au, 0xc4du}, + {0xc55u, 0xc56u}, + {0xc62u, 0xc63u}, + {0xc81u, 0xc81u}, + {0xcbcu, 0xcbcu}, + {0xcbfu, 0xcc0u}, + {0xcc2u, 0xcc2u}, + {0xcc6u, 0xcc8u}, + {0xccau, 0xccdu}, + {0xcd5u, 0xcd6u}, + {0xce2u, 0xce3u}, + {0xd00u, 0xd01u}, + {0xd3bu, 0xd3cu}, + {0xd3eu, 0xd3eu}, + {0xd41u, 0xd44u}, + {0xd4du, 0xd4eu}, + {0xd57u, 0xd57u}, + {0xd62u, 0xd63u}, + {0xd81u, 0xd81u}, + {0xdcau, 0xdcau}, + {0xdcfu, 0xdcfu}, + {0xdd2u, 0xdd4u}, + {0xdd6u, 0xdd6u}, + {0xddfu, 0xddfu}, + {0xe31u, 0xe31u}, + {0xe34u, 0xe3au}, + {0xe47u, 0xe4eu}, + {0xeb1u, 0xeb1u}, + {0xeb4u, 0xebcu}, + {0xec8u, 0xeceu}, + {0xf18u, 0xf19u}, + {0xf35u, 0xf35u}, + {0xf37u, 0xf37u}, + {0xf39u, 0xf39u}, + {0xf71u, 0xf7eu}, + {0xf80u, 0xf84u}, + {0xf86u, 0xf87u}, + {0xf8du, 0xf97u}, + {0xf99u, 0xfbcu}, + {0xfc6u, 0xfc6u}, + {0x102du, 0x1030u}, + {0x1032u, 0x1037u}, + {0x1039u, 0x103au}, + {0x103du, 0x103eu}, + {0x1058u, 0x1059u}, + {0x105eu, 0x1060u}, + {0x1071u, 0x1074u}, + {0x1082u, 0x1082u}, + {0x1085u, 0x1086u}, + {0x108du, 0x108du}, + {0x109du, 0x109du}, + {0x1160u, 0x11ffu}, + {0x135du, 0x135fu}, + {0x1712u, 0x1715u}, + {0x1732u, 0x1734u}, + {0x1752u, 0x1753u}, + {0x1772u, 0x1773u}, + {0x17b4u, 0x17b5u}, + {0x17b7u, 0x17bdu}, + {0x17c6u, 0x17c6u}, + {0x17c9u, 0x17d3u}, + {0x17ddu, 0x17ddu}, + {0x180bu, 0x180fu}, + {0x1885u, 0x1886u}, + {0x18a9u, 0x18a9u}, + {0x1920u, 0x1922u}, + {0x1927u, 0x1928u}, + {0x1932u, 0x1932u}, + {0x1939u, 0x193bu}, + {0x1a17u, 0x1a18u}, + {0x1a1bu, 0x1a1bu}, + {0x1a56u, 0x1a56u}, + {0x1a58u, 0x1a5eu}, + {0x1a60u, 0x1a60u}, + {0x1a62u, 0x1a62u}, + {0x1a65u, 0x1a6cu}, + {0x1a73u, 0x1a7cu}, + {0x1a7fu, 0x1a7fu}, + {0x1ab0u, 0x1addu}, + {0x1ae0u, 0x1aebu}, + {0x1b00u, 0x1b03u}, + {0x1b34u, 0x1b3du}, + {0x1b42u, 0x1b44u}, + {0x1b6bu, 0x1b73u}, + {0x1b80u, 0x1b81u}, + {0x1ba2u, 0x1ba5u}, + {0x1ba8u, 0x1badu}, + {0x1be6u, 0x1be6u}, + {0x1be8u, 0x1be9u}, + {0x1bedu, 0x1bedu}, + {0x1befu, 0x1bf3u}, + {0x1c2cu, 0x1c33u}, + {0x1c36u, 0x1c37u}, + {0x1cd0u, 0x1cd2u}, + {0x1cd4u, 0x1ce0u}, + {0x1ce2u, 0x1ce8u}, + {0x1cedu, 0x1cedu}, + {0x1cf4u, 0x1cf4u}, + {0x1cf8u, 0x1cf9u}, + {0x1dc0u, 0x1dffu}, + {0x200bu, 0x200fu}, + {0x202au, 0x202eu}, + {0x2060u, 0x206fu}, + {0x20d0u, 0x20f0u}, + {0x2cefu, 0x2cf1u}, + {0x2de0u, 0x2dffu}, + {0x302au, 0x302fu}, + {0x3099u, 0x309au}, + {0x3164u, 0x3164u}, + {0xa66fu, 0xa672u}, + {0xa674u, 0xa67du}, + {0xa69eu, 0xa69fu}, + {0xa6f0u, 0xa6f1u}, + {0xa802u, 0xa802u}, + {0xa806u, 0xa806u}, + {0xa80bu, 0xa80bu}, + {0xa825u, 0xa826u}, + {0xa82cu, 0xa82cu}, + {0xa8c4u, 0xa8c5u}, + {0xa8e0u, 0xa8f1u}, + {0xa8fau, 0xa8fau}, + {0xa8ffu, 0xa8ffu}, + {0xa926u, 0xa92du}, + {0xa947u, 0xa951u}, + {0xa953u, 0xa953u}, + {0xa980u, 0xa982u}, + {0xa9b3u, 0xa9b3u}, + {0xa9b6u, 0xa9b9u}, + {0xa9bcu, 0xa9bdu}, + {0xa9c0u, 0xa9c0u}, + {0xa9e5u, 0xa9e5u}, + {0xaa29u, 0xaa2eu}, + {0xaa31u, 0xaa32u}, + {0xaa35u, 0xaa36u}, + {0xaa43u, 0xaa43u}, + {0xaa4cu, 0xaa4cu}, + {0xaa7cu, 0xaa7cu}, + {0xaab0u, 0xaab0u}, + {0xaab2u, 0xaab4u}, + {0xaab7u, 0xaab8u}, + {0xaabeu, 0xaabfu}, + {0xaac1u, 0xaac1u}, + {0xaaecu, 0xaaedu}, + {0xaaf6u, 0xaaf6u}, + {0xabe5u, 0xabe5u}, + {0xabe8u, 0xabe8u}, + {0xabedu, 0xabedu}, + {0xd7b0u, 0xd7c6u}, + {0xd7cbu, 0xd7fbu}, + {0xfb1eu, 0xfb1eu}, + {0xfe00u, 0xfe0fu}, + {0xfe20u, 0xfe2fu}, + {0xfeffu, 0xfeffu}, + {0xff9eu, 0xffa0u}, + {0xfff0u, 0xfff8u}, + {0x101fdu, 0x101fdu}, + {0x102e0u, 0x102e0u}, + {0x10376u, 0x1037au}, + {0x10a01u, 0x10a03u}, + {0x10a05u, 0x10a06u}, + {0x10a0cu, 0x10a0fu}, + {0x10a38u, 0x10a3au}, + {0x10a3fu, 0x10a3fu}, + {0x10ae5u, 0x10ae6u}, + {0x10d24u, 0x10d27u}, + {0x10d69u, 0x10d6du}, + {0x10eabu, 0x10eacu}, + {0x10efau, 0x10effu}, + {0x10f46u, 0x10f50u}, + {0x10f82u, 0x10f85u}, + {0x11001u, 0x11001u}, + {0x11038u, 0x11046u}, + {0x11070u, 0x11070u}, + {0x11073u, 0x11074u}, + {0x1107fu, 0x11081u}, + {0x110b3u, 0x110b6u}, + {0x110b9u, 0x110bau}, + {0x110c2u, 0x110c2u}, + {0x11100u, 0x11102u}, + {0x11127u, 0x1112bu}, + {0x1112du, 0x11134u}, + {0x11173u, 0x11173u}, + {0x11180u, 0x11181u}, + {0x111b6u, 0x111beu}, + {0x111c0u, 0x111c0u}, + {0x111c2u, 0x111c3u}, + {0x111c9u, 0x111ccu}, + {0x111cfu, 0x111cfu}, + {0x1122fu, 0x11231u}, + {0x11234u, 0x11237u}, + {0x1123eu, 0x1123eu}, + {0x11241u, 0x11241u}, + {0x112dfu, 0x112dfu}, + {0x112e3u, 0x112eau}, + {0x11300u, 0x11301u}, + {0x1133bu, 0x1133cu}, + {0x1133eu, 0x1133eu}, + {0x11340u, 0x11340u}, + {0x1134du, 0x1134du}, + {0x11357u, 0x11357u}, + {0x11366u, 0x1136cu}, + {0x11370u, 0x11374u}, + {0x113b8u, 0x113b8u}, + {0x113bbu, 0x113c0u}, + {0x113c2u, 0x113c2u}, + {0x113c5u, 0x113c5u}, + {0x113c7u, 0x113c9u}, + {0x113ceu, 0x113d2u}, + {0x113e1u, 0x113e2u}, + {0x11438u, 0x1143fu}, + {0x11442u, 0x11444u}, + {0x11446u, 0x11446u}, + {0x1145eu, 0x1145eu}, + {0x114b0u, 0x114b0u}, + {0x114b3u, 0x114b8u}, + {0x114bau, 0x114bau}, + {0x114bdu, 0x114bdu}, + {0x114bfu, 0x114c0u}, + {0x114c2u, 0x114c3u}, + {0x115afu, 0x115afu}, + {0x115b2u, 0x115b5u}, + {0x115bcu, 0x115bdu}, + {0x115bfu, 0x115c0u}, + {0x115dcu, 0x115ddu}, + {0x11633u, 0x1163au}, + {0x1163du, 0x1163du}, + {0x1163fu, 0x11640u}, + {0x116abu, 0x116abu}, + {0x116adu, 0x116adu}, + {0x116b0u, 0x116b7u}, + {0x1171du, 0x1171du}, + {0x1171fu, 0x1171fu}, + {0x11722u, 0x11725u}, + {0x11727u, 0x1172bu}, + {0x1182fu, 0x11837u}, + {0x11839u, 0x1183au}, + {0x11930u, 0x11930u}, + {0x1193bu, 0x1193fu}, + {0x11941u, 0x11941u}, + {0x11943u, 0x11943u}, + {0x119d4u, 0x119d7u}, + {0x119dau, 0x119dbu}, + {0x119e0u, 0x119e0u}, + {0x11a01u, 0x11a0au}, + {0x11a33u, 0x11a38u}, + {0x11a3bu, 0x11a3eu}, + {0x11a47u, 0x11a47u}, + {0x11a51u, 0x11a56u}, + {0x11a59u, 0x11a5bu}, + {0x11a84u, 0x11a96u}, + {0x11a98u, 0x11a99u}, + {0x11b60u, 0x11b60u}, + {0x11b62u, 0x11b64u}, + {0x11b66u, 0x11b66u}, + {0x11c30u, 0x11c36u}, + {0x11c38u, 0x11c3du}, + {0x11c3fu, 0x11c3fu}, + {0x11c92u, 0x11ca7u}, + {0x11caau, 0x11cb0u}, + {0x11cb2u, 0x11cb3u}, + {0x11cb5u, 0x11cb6u}, + {0x11d31u, 0x11d36u}, + {0x11d3au, 0x11d3au}, + {0x11d3cu, 0x11d3du}, + {0x11d3fu, 0x11d47u}, + {0x11d90u, 0x11d91u}, + {0x11d95u, 0x11d95u}, + {0x11d97u, 0x11d97u}, + {0x11ef3u, 0x11ef4u}, + {0x11f00u, 0x11f02u}, + {0x11f36u, 0x11f3au}, + {0x11f40u, 0x11f42u}, + {0x11f5au, 0x11f5au}, + {0x13440u, 0x13440u}, + {0x13447u, 0x13455u}, + {0x1611eu, 0x16129u}, + {0x1612du, 0x1612fu}, + {0x16af0u, 0x16af4u}, + {0x16b30u, 0x16b36u}, + {0x16f4fu, 0x16f4fu}, + {0x16f8fu, 0x16f92u}, + {0x16fe4u, 0x16fe4u}, + {0x16ff0u, 0x16ff1u}, + {0x1bc9du, 0x1bc9eu}, + {0x1bca0u, 0x1bca3u}, + {0x1cf00u, 0x1cf2du}, + {0x1cf30u, 0x1cf46u}, + {0x1d165u, 0x1d169u}, + {0x1d16du, 0x1d182u}, + {0x1d185u, 0x1d18bu}, + {0x1d1aau, 0x1d1adu}, + {0x1d242u, 0x1d244u}, + {0x1da00u, 0x1da36u}, + {0x1da3bu, 0x1da6cu}, + {0x1da75u, 0x1da75u}, + {0x1da84u, 0x1da84u}, + {0x1da9bu, 0x1da9fu}, + {0x1daa1u, 0x1daafu}, + {0x1e000u, 0x1e006u}, + {0x1e008u, 0x1e018u}, + {0x1e01bu, 0x1e021u}, + {0x1e023u, 0x1e024u}, + {0x1e026u, 0x1e02au}, + {0x1e08fu, 0x1e08fu}, + {0x1e130u, 0x1e136u}, + {0x1e2aeu, 0x1e2aeu}, + {0x1e2ecu, 0x1e2efu}, + {0x1e4ecu, 0x1e4efu}, + {0x1e5eeu, 0x1e5efu}, + {0x1e6e3u, 0x1e6e3u}, + {0x1e6e6u, 0x1e6e6u}, + {0x1e6eeu, 0x1e6efu}, + {0x1e6f5u, 0x1e6f5u}, + {0x1e8d0u, 0x1e8d6u}, + {0x1e944u, 0x1e94au}, +}; +static const size_t zw_n = 394; + +static const uint32_t wd[][2] = { + {0x1100u, 0x115fu}, + {0x17a4u, 0x17a4u}, + {0x231au, 0x231bu}, + {0x2329u, 0x232au}, + {0x23e9u, 0x23ecu}, + {0x23f0u, 0x23f0u}, + {0x23f3u, 0x23f3u}, + {0x25fdu, 0x25feu}, + {0x2614u, 0x2615u}, + {0x2630u, 0x2637u}, + {0x2648u, 0x2653u}, + {0x267fu, 0x267fu}, + {0x268au, 0x268fu}, + {0x2693u, 0x2693u}, + {0x26a1u, 0x26a1u}, + {0x26aau, 0x26abu}, + {0x26bdu, 0x26beu}, + {0x26c4u, 0x26c5u}, + {0x26ceu, 0x26ceu}, + {0x26d4u, 0x26d4u}, + {0x26eau, 0x26eau}, + {0x26f2u, 0x26f3u}, + {0x26f5u, 0x26f5u}, + {0x26fau, 0x26fau}, + {0x26fdu, 0x26fdu}, + {0x2705u, 0x2705u}, + {0x270au, 0x270bu}, + {0x2728u, 0x2728u}, + {0x274cu, 0x274cu}, + {0x274eu, 0x274eu}, + {0x2753u, 0x2755u}, + {0x2757u, 0x2757u}, + {0x2795u, 0x2797u}, + {0x27b0u, 0x27b0u}, + {0x27bfu, 0x27bfu}, + {0x2b1bu, 0x2b1cu}, + {0x2b50u, 0x2b50u}, + {0x2b55u, 0x2b55u}, + {0x2e80u, 0x2e99u}, + {0x2e9bu, 0x2ef3u}, + {0x2f00u, 0x2fd5u}, + {0x2ff0u, 0x3029u}, + {0x3030u, 0x303eu}, + {0x3041u, 0x3096u}, + {0x309bu, 0x30ffu}, + {0x3105u, 0x312fu}, + {0x3131u, 0x3163u}, + {0x3165u, 0x318eu}, + {0x3190u, 0x31e5u}, + {0x31efu, 0x321eu}, + {0x3220u, 0x3247u}, + {0x3250u, 0xa48cu}, + {0xa490u, 0xa4c6u}, + {0xa960u, 0xa97cu}, + {0xac00u, 0xd7a3u}, + {0xf900u, 0xfaffu}, + {0xfe10u, 0xfe19u}, + {0xfe30u, 0xfe52u}, + {0xfe54u, 0xfe66u}, + {0xfe68u, 0xfe6bu}, + {0xff01u, 0xff60u}, + {0xffe0u, 0xffe6u}, + {0x16fe0u, 0x16fe3u}, + {0x16ff2u, 0x16ff6u}, + {0x17000u, 0x18cd5u}, + {0x18cffu, 0x18d1eu}, + {0x18d80u, 0x18df2u}, + {0x1aff0u, 0x1aff3u}, + {0x1aff5u, 0x1affbu}, + {0x1affdu, 0x1affeu}, + {0x1b000u, 0x1b122u}, + {0x1b132u, 0x1b132u}, + {0x1b150u, 0x1b152u}, + {0x1b155u, 0x1b155u}, + {0x1b164u, 0x1b167u}, + {0x1b170u, 0x1b2fbu}, + {0x1d300u, 0x1d356u}, + {0x1d360u, 0x1d376u}, + {0x1f004u, 0x1f004u}, + {0x1f0cfu, 0x1f0cfu}, + {0x1f18eu, 0x1f18eu}, + {0x1f191u, 0x1f19au}, + {0x1f200u, 0x1f202u}, + {0x1f210u, 0x1f23bu}, + {0x1f240u, 0x1f248u}, + {0x1f250u, 0x1f251u}, + {0x1f260u, 0x1f265u}, + {0x1f300u, 0x1f320u}, + {0x1f32du, 0x1f335u}, + {0x1f337u, 0x1f37cu}, + {0x1f37eu, 0x1f393u}, + {0x1f3a0u, 0x1f3cau}, + {0x1f3cfu, 0x1f3d3u}, + {0x1f3e0u, 0x1f3f0u}, + {0x1f3f4u, 0x1f3f4u}, + {0x1f3f8u, 0x1f43eu}, + {0x1f440u, 0x1f440u}, + {0x1f442u, 0x1f4fcu}, + {0x1f4ffu, 0x1f53du}, + {0x1f54bu, 0x1f54eu}, + {0x1f550u, 0x1f567u}, + {0x1f57au, 0x1f57au}, + {0x1f595u, 0x1f596u}, + {0x1f5a4u, 0x1f5a4u}, + {0x1f5fbu, 0x1f64fu}, + {0x1f680u, 0x1f6c5u}, + {0x1f6ccu, 0x1f6ccu}, + {0x1f6d0u, 0x1f6d2u}, + {0x1f6d5u, 0x1f6d8u}, + {0x1f6dcu, 0x1f6dfu}, + {0x1f6ebu, 0x1f6ecu}, + {0x1f6f4u, 0x1f6fcu}, + {0x1f7e0u, 0x1f7ebu}, + {0x1f7f0u, 0x1f7f0u}, + {0x1f90cu, 0x1f93au}, + {0x1f93cu, 0x1f945u}, + {0x1f947u, 0x1f9ffu}, + {0x1fa70u, 0x1fa7cu}, + {0x1fa80u, 0x1fa8au}, + {0x1fa8eu, 0x1fac6u}, + {0x1fac8u, 0x1fac8u}, + {0x1facdu, 0x1fadcu}, + {0x1fadfu, 0x1faeau}, + {0x1faefu, 0x1faf8u}, + {0x20000u, 0x2fffdu}, +}; +static const size_t wd_n = 125; + +static int range_has(const uint32_t t[][2], size_t n, uint32_t cp) { + size_t lo = 0, hi = n; + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + if (t[mid][0] > cp) hi = mid; + else if (t[mid][1] < cp) lo = mid + 1; + else return 1; + } + return 0; +} + +static int charw(size_t cp) { + if (cp < 0x20 || (cp >= 0x7F && cp <= 0x9F)) return 0; /* C0, DEL, C1 */ + if (range_has(zw, zw_n, (uint32_t)cp)) return 0; + if (range_has(wd, wd_n, (uint32_t)cp)) return 2; + return 1; +} + +size_t input_display_len_n(const char *s, size_t n) { + size_t width = 0; + const char *p = s; + const char *end = s + n; + while (p < end) { + size_t cp; + const char *nxt = utf8_next(p, end, &cp); + if (cp == 0x1B) { + if (nxt >= end) break; /* lone trailing ESC: not rendered */ + unsigned char second = (unsigned char)*nxt; + if (second == '[') { + const char *q = nxt + 1; + while (q < end) { + size_t c2; + const char *q2 = utf8_next(q, end, &c2); + if (c2 >= 0x40 && c2 <= 0x7E) break; + q = q2; + } + if (q >= end) return width; + size_t c3; + p = utf8_next(q, end, &c3); + continue; + } + if (second == ']' || second == 'P' || second == '^' || second == 'X' || + second == '_') { + /* OSC / DCS / SOS / PM / APC: scan to BEL or ESC \ */ + int prev_esc = 0; + const char *q = nxt + 1; + while (q < end) { + unsigned char c = (unsigned char)*q; + if (c == 0x07 || (prev_esc && c == '\\')) break; + prev_esc = (c == 0x1B); + q++; + } + if (q >= end) return width; + p = q + 1; + continue; + } + if (second == 'O') { + /* SS3: `O` plus one more final byte (e.g. ESC O A). */ + if (nxt + 1 >= end) return width; + size_t c3; + p = utf8_next(nxt + 1, end, &c3); + continue; + } + if (second >= 0x20 && second <= 0x7E) { + /* Intermediates 0x20..=0x2F then one final byte; a bare + * final byte ends the sequence on the spot. */ + const char *q = nxt + 1; + while (q < end) { + unsigned char c = (unsigned char)*q; + if (c < 0x20 || c > 0x2F) break; + q++; + } + if (q >= end) return width; + size_t c3; + p = utf8_next(q, end, &c3); + continue; + } + p = nxt; /* other: lone ESC */ + continue; + } + if (cp != 0x7F) width += (size_t)charw(cp); + p = nxt; + } + return width; +} + +size_t input_display_len(const char *s) { + return input_display_len_n(s, strlen(s)); +} + +/* ================================================================== */ +/* Parser */ +/* ================================================================== */ + +void key_parser_init(key_parser *p) { + p->state = KST_GROUND; + xbuf_init(&p->seq); + xbuf_init(&p->csi_params); + p->utf8_len = 0; + p->pasting = false; +} + +void key_parser_free(key_parser *p) { + xbuf_free(&p->seq); + xbuf_free(&p->csi_params); + p->state = KST_GROUND; + p->utf8_len = 0; + p->pasting = false; +} + +static void kp_emit(key_parser *p, evlist *out, int kind, unsigned int cp) { + evlist_push(out, (input_key){kind, cp}, (const unsigned char *)p->seq.data, + p->seq.len); + xbuf_reset(&p->seq); + xbuf_reset(&p->csi_params); +} + +static int kp_digit(unsigned char b) { + return b >= '0' && b <= '9'; +} + +static int first_num(const char *s, unsigned long *out) { + unsigned long d = 0; + int any = 0; + for (const char *p = s; *p >= '0' && *p <= '9'; p++) { + if (any) d = d * 10 + (unsigned long)(*p - '0'); + else + d = (unsigned long)(*p - '0'); + any = 1; + if (d > 0xFFFFFFFFUL) break; + } + if (!any) return 0; + *out = d; + return 1; +} + +static int csi_u(const char *params, int *key) { + const char *semi = strchr(params, ';'); + size_t first_len = semi ? (size_t)(semi - params) : strlen(params); + if (first_len == 0 || first_len > 10) return 0; + unsigned long cp = 0; + for (size_t i = 0; i < first_len; i++) { + cp = cp * 10 + (unsigned long)(params[i] - '0'); + if (cp > 0xFFFFFFFFUL) return 0; + } + unsigned long mods = 1; + if (semi) { + const char *m = semi + 1; + size_t mlen = strlen(m); + if (mlen > 0 && mlen <= 10) { + unsigned long v = 0; + int ok = 1; + for (size_t i = 0; i < mlen; i++) { + if (m[i] < '0' || m[i] > '9') { + ok = 0; + break; + } + v = v * 10 + (unsigned long)(m[i] - '0'); + if (v > 0xFFFFFFFFUL) { + ok = 0; + break; + } + } + if (ok) mods = v; + } + } + int shift = ((mods - 1UL) & 1UL) == 1UL; + int k; + switch (cp) { + case 9u: + k = shift ? KEY_BACKTAB : KEY_TAB; + break; + case 12u: + k = KEY_BACKSPACE; + break; + case 13u: + k = KEY_ENTER; + break; + case 27u: + k = KEY_ESC; + break; + case 127u: + k = KEY_DELETE; + break; + case 0x2190u: + k = KEY_LEFT; + break; + case 0x2191u: + k = KEY_UP; + break; + case 0x2192u: + k = KEY_RIGHT; + break; + case 0x2193u: + k = KEY_DOWN; + break; + default: + return 0; + } + *key = k; + return 1; +} + +static int kp_ground(key_parser *p, unsigned char b, evlist *out); + +static size_t utf8_lead_len(unsigned char lead) { + if (lead >= 0xF0 && lead < 0xF8) return 4; + if (lead >= 0xE0) return 3; + if (lead >= 0xC0) return 2; + return 1; +} + +static int kp_utf8_step(key_parser *p, unsigned char b, evlist *out) { + if (p->utf8_len == 0) { + if (b < 0xC0 || b >= 0xF8) { + p->utf8_len = 0; + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_UNKNOWN, 0); + return 1; + } + p->utf8[p->utf8_len++] = b; + return 0; + } + if (b >= 0x80 && b < 0xC0) { + if (p->utf8_len < 8) p->utf8[p->utf8_len++] = b; + size_t expected = utf8_lead_len(p->utf8[0]); + if (p->utf8_len < expected) return 0; + if (utf8_is_valid((const char *)p->utf8, p->utf8_len)) { + size_t cp = 0; + utf8_next((const char *)p->utf8, (const char *)p->utf8 + p->utf8_len, + &cp); + unsigned char tmp[8]; + size_t n = p->utf8_len; + memcpy(tmp, p->utf8, n); + p->utf8_len = 0; + xbuf_reset(&p->seq); + for (size_t i = 0; i < n; i++) xbuf_push(&p->seq, (char)tmp[i]); + kp_emit(p, out, KEY_CHAR, cp); + return 1; + } + p->utf8_len = 0; + return 0; + } + p->utf8_len = 0; + return kp_ground(p, b, out); +} + +static int kp_ground(key_parser *p, unsigned char b, evlist *out) { + if (b == 0x1B) { + p->state = KST_ESCAPE; + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + return 0; + } + if (b == 0x09) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_TAB, 0); + return 1; + } + if (b == 0x0D || b == 0x0A) { + /* CR (0x0D) submits. During bracketed paste every CR/LF is a newline, + * preserving multi-line pastes. LF (0x0A, Ctrl+J) outside of paste is + * a dead key. Multi-line typing is done with backslash+Enter. */ + if (p->pasting) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_NEWLINE, 0); + return 1; + } + if (b == 0x0D) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_ENTER, 0); + return 1; + } + xbuf_reset(&p->seq); /* discard LF: dead key, no event */ + return 0; + } + if (b == 0x7F || b == 0x08) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_BACKSPACE, 0); + return 1; + } + switch (b) { + case 0x01: + case 0x03: + case 0x04: + case 0x05: + case 0x0B: + case 0x0C: + case 0x15: + case 0x17: + case 0x19: + case 0x1F: { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + int k; + switch (b) { + case 0x01: k = KEY_CTRL_A; break; + case 0x03: k = KEY_CTRL_C; break; + case 0x04: k = KEY_CTRL_D; break; + case 0x05: k = KEY_CTRL_E; break; + case 0x0B: k = KEY_CTRL_K; break; + case 0x0C: k = KEY_CTRL_L; break; + case 0x15: k = KEY_CTRL_U; break; + case 0x19: k = KEY_CTRL_Y; break; + case 0x1F: k = KEY_UNDO; break; + default: k = KEY_CTRL_W; break; + } + /* A bare Ctrl+C is an interrupt, not pasted text: if a bracketed + * paste start (CSI 200~) was received but its 201~ terminator never + * arrived, paste mode would otherwise stick and turn every Enter into + * a newline. Reset it so the user can submit again. */ + if (b == 0x03) + p->pasting = false; + kp_emit(p, out, k, 0); + return 1; + } + default: + break; + } + if (b < 0x20) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_UNKNOWN, 0); + return 1; + } + if (b < 0x80) { + xbuf_reset(&p->seq); + xbuf_push(&p->seq, (char)b); + kp_emit(p, out, KEY_CHAR, b); + return 1; + } + return kp_utf8_step(p, b, out); +} + +static int kp_escape(key_parser *p, unsigned char b, evlist *out) { + p->state = KST_GROUND; + xbuf_push(&p->seq, (char)b); + if (b == '[') { + p->state = KST_CSI; + xbuf_reset(&p->csi_params); + return 0; + } + if (b == 'O') { + p->state = KST_SS3; + return 0; + } + if (b >= 0x20 && b <= 0x7E) { + kp_emit(p, out, KEY_CHAR, b); + return 1; + } + kp_emit(p, out, KEY_ESC, 0); + return 1; +} + +static int kp_csi(key_parser *p, unsigned char b, evlist *out) { + xbuf_push(&p->seq, (char)b); + if (kp_digit(b) || b == ';' || b == '<' || b == '=' || b == '?' || b == '>') { + xbuf_push(&p->csi_params, (char)b); + return 0; + } + p->state = KST_GROUND; + char *params = xbuf_steal(&p->csi_params); + int k = -1; + if (b == 'A') + k = KEY_UP; + else if (b == 'B') + k = KEY_DOWN; + else if (b == 'C') + k = KEY_RIGHT; + else if (b == 'D') + k = KEY_LEFT; + else if (b == 'H') + k = KEY_HOME; + else if (b == 'F') + k = KEY_END; + else if (b == 'Z') + k = KEY_BACKTAB; + else if (b == 'u') + k = csi_u(params, &k) ? k : -1; + else if (b == '~') { + unsigned long n; + if (first_num(params, &n)) { + switch (n) { + case 200u: + free(params); + p->pasting = true; + return 0; + case 201u: + free(params); + p->pasting = false; + return 0; + case 1u: + case 7u: + k = KEY_HOME; + break; + case 3u: + k = KEY_DELETE; + break; + case 4u: + case 8u: + k = KEY_END; + break; + case 5u: + k = KEY_PAGEUP; + break; + case 6u: + k = KEY_PAGEDOWN; + break; + default: + k = KEY_UNKNOWN; + break; + } + } else { + k = KEY_UNKNOWN; + } + } + else if (b == 'R' || b == 'M' || b == 'h' || b == 'l' || b == 'm') + k = -1; + free(params); + if (k < 0) return 0; + kp_emit(p, out, k, 0); + return 1; +} + +static int kp_ss3(key_parser *p, unsigned char b, evlist *out) { + xbuf_push(&p->seq, (char)b); + p->state = KST_GROUND; + int k; + switch (b) { + case 'A': k = KEY_UP; break; + case 'B': k = KEY_DOWN; break; + case 'C': k = KEY_RIGHT; break; + case 'D': k = KEY_LEFT; break; + case 'H': k = KEY_HOME; break; + case 'F': k = KEY_END; break; + default: k = KEY_UNKNOWN; break; + } + kp_emit(p, out, k, 0); + return 1; +} + +size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out) { + size_t emitted = 0; + for (size_t i = 0; i < n; i++) { + unsigned char b = in[i]; + switch (p->state) { + case KST_ESCAPE: + emitted += kp_escape(p, b, out); + break; + case KST_CSI: + emitted += kp_csi(p, b, out); + break; + case KST_SS3: + emitted += kp_ss3(p, b, out); + break; + default: + emitted += kp_ground(p, b, out); + break; + } + } + return emitted; +} + +int key_parser_flush(key_parser *p, evlist *out) { + if (p->state != KST_ESCAPE) return 0; + p->state = KST_GROUND; + kp_emit(p, out, KEY_ESC, 0); + return 1; +} + +/* ================================================================== */ +/* Editor */ +/* ================================================================== */ + +static size_t char_start(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t j = pos; + while (j > 0 && (((unsigned char)s[j - 1]) & 0xC0) == 0x80) j--; + return j == 0 ? 0 : j - 1; +} + +static size_t char_end(const char *s, size_t len, size_t pos) { + if (pos >= len) return len; + size_t cp; + const char *nxt = utf8_next(s + pos, s + len, &cp); + return (size_t)(nxt - s); +} + +/* Byte offset of the first column of the logical line holding `pos`. */ +static size_t line_start_of(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t i = pos; + while (i > 0 && s[i - 1] != '\n') i--; + return i; +} + +/* Byte offset of the line terminator ('\n') for the line holding `pos`, or + * end-of-string when that line is the last one. */ +static size_t line_end_of(const char *s, size_t pos) { + size_t len = strlen(s); + if (pos > len) pos = len; + size_t i = pos; + while (i < len && s[i] != '\n') i++; + return i; +} + +/* Physical (row, col) of a byte offset in the buffer, tracking the *visual* + * cursor position the way a delayed-wrap terminal (xterm/VTE) actually keeps + * it: writing a character into the last column leaves the cursor sitting on + * that last column with a pending wrap, the wrap fires only when the *next* + * character is written, and a newline clears the pending wrap. So the cursor + * never sits on an empty spurious row — a full-width line followed by a + * newline does not gain one. Line 0 is prefixed by the 2-column CHAT_PROMPT. + * The returned (row, col) is exactly where the terminal cursor is after the + * text up to that byte has been printed, so relative cursor moves from it are + * reliable. */ +static void input_wrap_caret_impl(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col, + bool *out_pending) { + if (w == 0) w = 1; + size_t len = text ? strlen(text) : 0; + if (caret > len) caret = len; + size_t r = 0, c = 0; + int pending = 0; +#define ADV_COL() do { \ + if (pending) { pending = 0; r++; c = 0; } \ + c++; \ + if (c == w) { pending = 1; c = w - 1; } \ + } while (0) + /* The 2-column prompt is always printed in front of line 0. */ + ADV_COL(); + ADV_COL(); + size_t byte = 0; + while (byte < caret) { + size_t cp; + const char *nxt = utf8_next(text + byte, text + len, &cp); + size_t cl = (size_t)(nxt - (text + byte)); + if (cl == 0) cl = 1; + if (cp == '\n') { + pending = 0; + c = 0; + r++; + } else { + ADV_COL(); + } + byte += cl; + } +#undef ADV_COL + if (out_row) *out_row = r; + if (out_col) *out_col = c; + if (out_pending) *out_pending = (pending != 0); +} + +void input_wrap_caret(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col) { + input_wrap_caret_impl(text, caret, w, out_row, out_col, NULL); +} + +bool input_wrap_pending(const char *text, size_t caret, size_t w) { + bool pending = false; + input_wrap_caret_impl(text, caret, w, NULL, NULL, &pending); + return pending; +} + +/* Total physical rows the buffer occupies when printed, i.e. the row of the + * visual end-of-text cursor plus one. */ +size_t input_wrap_total(const char *text, size_t w) { + size_t r, c; + const char *t = text ? text : ""; + input_wrap_caret(t, strlen(t), w, &r, &c); + return r + 1; +} + +static char *splice_remove(const char *s, size_t start, size_t end) { + size_t len = strlen(s); + char *out = (char *) xmalloc(len - (end - start) + 1); + memcpy(out, s, start); + memcpy(out + start, s + end, len - end); + out[len - (end - start)] = '\0'; + return out; +} + +static size_t word_backward(const char *s, size_t byte) { + size_t i = byte; + while (i > 0 && s[i - 1] == ' ') i--; + while (i > 0 && + (isalnum((unsigned char)s[i - 1]) || s[i - 1] == '_')) + i--; + while (i > 0 && s[i - 1] == ' ') i--; + return i; +} + +#define EDITOR_UNDO_LIMIT 100 + +void editor_init(editor *e) { + e->text = xstrdup(""); + e->caret = 0; + e->killed = NULL; + e->undo = NULL; + e->undo_len = 0; + e->undo_cap = 0; +} + +void editor_free(editor *e) { + free(e->text); + for (size_t i = 0; i < e->undo_len; i++) + free(e->undo[i].text); + free(e->undo); + free(e->killed); + memset(e, 0, sizeof(*e)); +} + +/* Snapshot the current buffer onto the bounded undo stack. */ +static void editor_record(editor *e) { + if (e->undo_len == e->undo_cap) { + size_t ncap = e->undo_cap ? e->undo_cap * 2 : 16; + if (ncap > EDITOR_UNDO_LIMIT) { + free(e->undo[0].text); + memmove(e->undo, e->undo + 1, (e->undo_len - 1) * sizeof(*e->undo)); + e->undo_len--; + } else { + e->undo = (editor_undo_entry *) xrealloc(e->undo, ncap * sizeof(*e->undo)); + e->undo_cap = ncap; + } + } + e->undo[e->undo_len].text = xstrdup(e->text ? e->text : ""); + e->undo[e->undo_len].caret = e->caret; + e->undo_len++; +} + +/* Restore the most recent snapshot. False when there is nothing to undo. */ +static bool editor_undo_pop(editor *e) { + if (e->undo_len == 0) + return false; + e->undo_len--; + free(e->text); + e->text = e->undo[e->undo_len].text; + e->undo[e->undo_len].text = NULL; + e->caret = e->undo[e->undo_len].caret; + return true; +} + +void editor_clear(editor *e) { + e->text[0] = '\0'; + e->caret = 0; +} + +void editor_set(editor *e, const char *text) { + free(e->text); + e->text = xstrdup(text); + e->caret = strlen(e->text); +} + +char *editor_text(const editor *e) { + return xstrdup(e->text); +} + +bool editor_is_empty(const editor *e) { + return e->text[0] == '\0'; +} + +size_t editor_line_count(const editor *e) { + size_t n = 1; + for (const char *p = e->text; *p; p++) + if (*p == '\n') n++; + return n; +} + +size_t editor_caret_line(const editor *e) { + size_t n = 0; + for (size_t i = 0; i < e->caret && e->text[i]; i++) + if (e->text[i] == '\n') n++; + return n; +} + +static void ed_insert_str(editor *e, const char *s) { + size_t len = strlen(e->text); + if (e->caret > len) e->caret = len; + size_t ins = strlen(s); + char *nt = (char *) xmalloc(len + ins + 1); + memcpy(nt, e->text, e->caret); + memcpy(nt + e->caret, s, ins); + memcpy(nt + e->caret + ins, e->text + e->caret, len - e->caret); + nt[len + ins] = '\0'; + free(e->text); + e->text = nt; + e->caret += ins; +} + +static void ed_insert_char(editor *e, size_t cp) { + char tmp[8]; + size_t i = 0; + if (cp < 0x80) + tmp[i++] = (char)cp; + else if (cp < 0x800) { + tmp[i++] = (char)(0xC0 | (cp >> 6)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + tmp[i++] = (char)(0xE0 | (cp >> 12)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else { + tmp[i++] = (char)(0xF0 | (cp >> 18)); + tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } + tmp[i] = '\0'; + ed_insert_str(e, tmp); +} + +static void ed_backspace(editor *e) { + if (e->caret > 0) { + size_t s = char_start(e->text, e->caret); + char *killed = xstrndup(e->text + s, e->caret - s); + char *nt = splice_remove(e->text, s, e->caret); + free(e->text); + e->text = nt; + e->caret = s; + free(e->killed); + e->killed = killed; + } +} + +static void ed_delete(editor *e) { + size_t len = strlen(e->text); + if (e->caret < len) { + size_t c = char_end(e->text, len, e->caret); + char *killed = xstrndup(e->text + e->caret, c - e->caret); + char *nt = splice_remove(e->text, e->caret, c); + free(e->text); + e->text = nt; + free(e->killed); + e->killed = killed; + } +} + +/* Move the caret to the previous (dir<0) or next (dir>0) line, keeping the + * column and clamping it to the target line's length on a UTF-8 boundary. + * No-op at the top/bottom edge. */ +static void ed_move_line(editor *e, int dir) { + const char *s = e->text; + size_t len = strlen(s); + if (e->caret > len) e->caret = len; + size_t start = e->caret; + while (start > 0 && s[start - 1] != '\n') start--; + size_t col = e->caret - start; + if (dir < 0) { + if (start == 0) return; /* first line */ + size_t pstart = start - 1; + while (pstart > 0 && s[pstart - 1] != '\n') pstart--; + size_t plen = (start - 1) - pstart; + size_t c = col < plen ? col : plen; + while (c > 0 && (((unsigned char)s[pstart + c]) & 0xC0) == 0x80) c--; + e->caret = pstart + c; + } else { + size_t end = e->caret; + while (end < len && s[end] != '\n') end++; + if (end >= len) return; /* last line */ + size_t nstart = end + 1; + size_t nend = nstart; + while (nend < len && s[nend] != '\n') nend++; + size_t nlen = nend - nstart; + size_t c = col < nlen ? col : nlen; + while (c > 0 && (((unsigned char)s[nstart + c]) & 0xC0) == 0x80) c--; + e->caret = nstart + c; + } +} + +void editor_apply(editor *e, input_key key, input_action *a) { + a->kind = ACT_NONE; + a->text = NULL; + switch (key.kind) { + case KEY_CHAR: + editor_record(e); + ed_insert_char(e, key.cp); + break; + case KEY_TAB: + editor_record(e); + ed_insert_str(e, "\t"); + break; + case KEY_NEWLINE: /* pasted CR/LF: insert a real newline */ + editor_record(e); + ed_insert_str(e, "\n"); + break; + case KEY_ENTER: { + /* Backslash + Enter: line continuation, not submit. Universal across + * terminals (works where Shift+Enter is not distinguishable from + * Enter). The backslash is the char immediately before the caret on + * its own line -- so a middle line of a multi-line buffer can be + * split, not just the last line. */ + if (e->caret > 0 && e->text[e->caret - 1] == '\\') { + editor_record(e); + e->text[e->caret - 1] = '\n'; + a->kind = ACT_NEWLINE; + break; + } + a->kind = ACT_SUBMIT; + a->text = editor_text(e); + break; + } + case KEY_BACKTAB: + a->kind = ACT_TOGGLE_MODE; + break; + case KEY_ESC: + a->kind = ACT_STOP; + break; + case KEY_BACKSPACE: + editor_record(e); + ed_backspace(e); + break; + case KEY_DELETE: + editor_record(e); + ed_delete(e); + break; + case KEY_LEFT: + /* Stay on the current line: the caret never crosses a newline to the + * left, so a press at the line start is a no-op. */ + if (e->caret > line_start_of(e->text, e->caret)) + e->caret = char_start(e->text, e->caret); + break; + case KEY_RIGHT: + if (e->caret < line_end_of(e->text, e->caret)) + e->caret = char_end(e->text, strlen(e->text), e->caret); + break; + case KEY_HOME: + case KEY_CTRL_A: + e->caret = line_start_of(e->text, e->caret); + break; + case KEY_END: + case KEY_CTRL_E: + e->caret = line_end_of(e->text, e->caret); + break; + case KEY_CTRL_U: + editor_record(e); + if (e->caret > 0) { + char *killed = xstrndup(e->text, e->caret); + char *keep = xstrdup(e->text + e->caret); + free(e->text); + e->text = keep; + e->caret = 0; + free(e->killed); + e->killed = killed; + } + break; + case KEY_CTRL_K: { + editor_record(e); + size_t len = strlen(e->text); + if (e->caret < len) { + char *killed = xstrdup(e->text + e->caret); + char *keep = xstrndup(e->text, e->caret); + free(e->text); + e->text = keep; + free(e->killed); + e->killed = killed; + } + break; + } + case KEY_CTRL_W: { + editor_record(e); + size_t i = word_backward(e->text, e->caret); + if (i < e->caret) { + char *killed = xstrndup(e->text + i, e->caret - i); + char *nt = splice_remove(e->text, i, e->caret); + free(e->text); + e->text = nt; + e->caret = i; + free(e->killed); + e->killed = killed; + } + break; + } + case KEY_CTRL_Y: + if (e->killed && e->killed[0]) { + editor_record(e); + ed_insert_str(e, e->killed); + } + break; + case KEY_UNDO: + editor_undo_pop(e); + break; + case KEY_CTRL_C: + if (editor_is_empty(e)) + a->kind = ACT_QUIT; + else { + editor_record(e); + editor_clear(e); + } + break; + case KEY_CTRL_D: + if (editor_is_empty(e)) + a->kind = ACT_QUIT; + else + ed_delete(e); + break; + case KEY_CTRL_L: + a->kind = ACT_CLEAR_SCREEN; + break; + case KEY_UP: + ed_move_line(e, -1); + break; + case KEY_DOWN: + ed_move_line(e, +1); + break; + case KEY_PAGEUP: + case KEY_PAGEDOWN: + case KEY_UNKNOWN: + break; + } +} + +/* ================================================================== */ +/* History */ +/* ================================================================== */ + +input_history *input_history_new(void) { + input_history *h = (input_history *) xmalloc(sizeof(*h)); + sl_init(&h->entries); + h->head = 0; + h->active = false; + h->index = 0; + h->snapshot = NULL; + h->path = NULL; + return h; +} + +void input_history_free(input_history *h) { + sl_free(&h->entries); + free(h->snapshot); + free(h->path); + free(h); +} + +void input_history_reset(input_history *h) { + h->active = false; + h->index = 0; + free(h->snapshot); + h->snapshot = NULL; +} + +static size_t hist_live(input_history *h) { + return h->entries.len > h->head ? h->entries.len - h->head : 0; +} + +static void history_evict_head(input_history *h) { + h->head++; + /* Amortized O(1): once half the storage is dead, free the dead prefix + * and slide the live entries to the front (absolute `index` moves with + * them). Evicted-but-uncompacted entries are freed by sl_free. */ + if (h->head * 2 >= h->entries.len) { + size_t live = h->entries.len - h->head; + for (size_t i = 0; i < h->head; i++) + free(h->entries.items[i]); + memmove(h->entries.items, h->entries.items + h->head, + live * sizeof(*h->entries.items)); + h->entries.len = live; + if (h->active) + h->index -= h->head; + h->head = 0; + } +} + +static int make_parent_dirs(const char *path) { + char *copy = xstrdup(path); + for (char *p = copy + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (copy[0] && mkdir(copy, 0755) != 0 && errno != EEXIST) { + free(copy); + return -1; + } + *p = '/'; + } + } + free(copy); + return 0; +} + +bool input_history_persist(input_history *h) { + if (!h->path) return false; + cJSON *arr = cJSON_CreateArray(); + for (size_t i = h->head; i < h->entries.len; i++) + cJSON_AddItemToArray(arr, cJSON_CreateString(h->entries.items[i])); + char *s = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + char *err = NULL; + bool ok = make_parent_dirs(h->path) == 0 && + write_file_from_buf(h->path, s, strlen(s), &err) == 0; + free(err); + free(s); + return ok; +} + +input_history *input_history_open(const char *path) { + input_history *h = input_history_new(); + h->path = xstrdup(path); + char *data = NULL; + size_t len = 0; + char *err = NULL; + int rc = read_file_to_buf(path, &data, &len, &err); + if (rc == 0) { + cJSON *arr = data ? cJSON_Parse(data) : NULL; + free(data); + if (arr && cJSON_IsArray(arr)) { + int n = cJSON_GetArraySize(arr); + for (int i = 0; i < n; i++) { + cJSON *it = cJSON_GetArrayItem(arr, i); + if (cJSON_IsString(it) && it->valuestring) + sl_push(&h->entries, xstrdup(it->valuestring)); + } + } + cJSON_Delete(arr); + } else { + free(err); + if (errno != ENOENT) { + input_history_free(h); + return NULL; + } + } + while (hist_live(h) > LLSH_HISTORY_LIMIT) history_evict_head(h); + return h; +} + +void input_history_push(input_history *h, const char *text) { + char *trimmed = xstr_trim_dup(text); + if (!trimmed[0]) { + free(trimmed); + input_history_reset(h); + return; + } + if (hist_live(h) > 0) { + const char *last = h->entries.items[h->entries.len - 1]; + if (xstreq(last, trimmed)) { + free(trimmed); + input_history_reset(h); + return; + } + } + if (hist_live(h) == LLSH_HISTORY_LIMIT) history_evict_head(h); + sl_push(&h->entries, trimmed); + input_history_reset(h); + input_history_persist(h); +} + +char *input_history_walk(input_history *h, int dir, size_t caret_line, + const char *current) { + if (dir < 0) { + /* Up: walk newest -> oldest. While composing (not active) engage + * only from the first line so a mid-buffer Up still moves the caret; + * the first Up snapshots the line being edited so a Down can restore + * it. Mid-walk (active) the press always advances, regardless of + * where the caret now sits. */ + if (!h->active) { + if (hist_live(h) == 0 || caret_line != 0) + return NULL; + free(h->snapshot); + h->snapshot = xstrdup(current ? current : ""); + h->active = true; + h->index = h->entries.len - 1; + } else if (h->index == h->head) { + return NULL; /* oldest already shown: hand back to the caret */ + } else { + h->index -= 1; + } + return xstrdup(h->entries.items[h->index]); + } + + /* Down: only meaningful mid-walk. Step back toward the newest entry, or, + * past the newest, hand back the pre-recall line and end the walk. + * While composing a Down never recalls — it moves the caret. */ + if (!h->active) + return NULL; + if (h->index + 1 < h->entries.len) { + h->index += 1; + return xstrdup(h->entries.items[h->index]); + } + h->active = false; + char *s = h->snapshot; + h->snapshot = NULL; + return s; +} + +/* ================================================================== */ +/* Terminal */ +/* ================================================================== */ + +int input_isatty(void) { + return isatty(STDIN_FILENO); +} + +int input_setup_terminal(void) { + struct termios orig; + if (tcgetattr(STDIN_FILENO, &orig) != 0) return -1; + struct termios raw = orig; + raw.c_iflag &= ~((tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON)); + raw.c_lflag &= ~((tcflag_t)(ECHO | ICANON | IEXTEN | ISIG)); + raw.c_oflag &= ~((tcflag_t)OPOST); + raw.c_cc[VMIN] = 1; + raw.c_cc[VTIME] = 0; + if (tcsetattr(STDIN_FILENO, TCSANOW, &raw) != 0) return -1; + struct termios t; + if (tcgetattr(STDIN_FILENO, &t) == 0) { + t.c_oflag |= (tcflag_t)(OPOST | ONLCR); + (void)tcsetattr(STDIN_FILENO, TCSANOW, &t); + } + const char *init = "\x1b[>1u\x1b[?2004h"; + (void)!write(STDOUT_FILENO, init, strlen(init)); + fflush(stdout); + return 0; +} + +void input_teardown_terminal(void) { + const char *fin = "\x1b[?2004l\x1b[>0u"; + (void)!write(STDOUT_FILENO, fin, strlen(fin)); + fflush(stdout); + struct termios t; + if (tcgetattr(STDIN_FILENO, &t) != 0) return; + t.c_lflag |= (tcflag_t)(ICANON | ECHO | ISIG | IEXTEN); + t.c_iflag |= (tcflag_t)(BRKINT | ICRNL | INPCK | ISTRIP | IXON); + t.c_oflag |= (tcflag_t)(OPOST | ONLCR); + t.c_cc[VMIN] = 1; + t.c_cc[VTIME] = 0; + (void)tcsetattr(STDIN_FILENO, TCSANOW, &t); +} + +/* ================================================================== */ +/* Reader */ +/* ================================================================== */ + +struct input_key_reader { + reader_ctx *ctx; + pthread_t tid; + int started; + volatile int stop; + int exit; + pthread_mutex_t qlock; + evlist q; +}; + +input_key_reader *input_key_reader_new(reader_ctx *ctx) { + input_key_reader *r = (input_key_reader *) xmalloc(sizeof(*r)); + memset(r, 0, sizeof(*r)); + r->ctx = ctx; + pthread_mutex_init(&r->qlock, NULL); + evlist_init(&r->q); + return r; +} + +void input_key_reader_send(input_key_reader *r, const input_key *k) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, (const unsigned char *)"", 0); + pthread_mutex_unlock(&r->qlock); +} + +void input_key_reader_send_raw(input_key_reader *r, const input_key *k, + const char *raw) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, (const unsigned char *)raw, strlen(raw)); + pthread_mutex_unlock(&r->qlock); +} + +bool input_key_reader_recv(input_key_reader *r, input_key *out, + const unsigned char **raw, size_t *raw_len) { + pthread_mutex_lock(&r->qlock); + if (r->q.len == 0) { + pthread_mutex_unlock(&r->qlock); + return false; + } + *out = r->q.items[r->q.head]; + size_t n = r->q.lens[r->q.head]; + if (raw) { + /* The queue frees its slot on pop, so hand the caller an owned copy; + * a borrowed pointer here was freed before the caller used it. */ + unsigned char *copy = (unsigned char* ) xmalloc(n + 1); + memcpy(copy, r->q.raws[r->q.head], n); + copy[n] = '\0'; + *raw = copy; + } + if (raw_len) *raw_len = n; + evlist_pop(&r->q); + pthread_mutex_unlock(&r->qlock); + return true; +} + +void input_raw_free(const unsigned char *raw) { + free((void *)raw); +} + +/* Queue the event and, if set, invoke the direct hook. + * Returns non-zero to stop reading (receiver gone). */ +static int reader_dispatch(input_key_reader *r, const input_key *k, + const unsigned char *raw, size_t raw_len) { + pthread_mutex_lock(&r->qlock); + evlist_push(&r->q, *k, raw, raw_len); + pthread_mutex_unlock(&r->qlock); + if (r->ctx && r->ctx->send) return r->ctx->send(r, k, raw, raw_len); + return 0; +} + +static void *reader_thread(void *arg) { + input_key_reader *r = (input_key_reader *) arg; + unsigned char buf[512]; + for (;;) { + if (r->stop) break; + struct pollfd pfd = {.fd = STDIN_FILENO, .events = POLLIN, .revents = 0}; + int pr = poll(&pfd, 1, 50); + if (pr < 0) { + if (errno == EINTR) continue; + break; + } + if (pr == 0) continue; + if ((pfd.revents & (POLLIN | POLLHUP | POLLERR)) == 0) continue; + ssize_t n = 0; + do { + n = read(STDIN_FILENO, buf, sizeof buf); + } while (n < 0 && errno == EINTR); + if (n < 0) break; + if (n == 0) break; /* EOF: no synthetic Ctrl-D */ + evlist evs; + evlist_init(&evs); + key_parser_feed(r->ctx->parser, buf, (size_t)n, &evs); + for (size_t j = 0; j < evs.len; j++) { + if (reader_dispatch(r, &evs.items[j], evs.raws[j], + evs.lens[j]) != 0) { + evlist_free(&evs); + r->exit = 1; + return NULL; + } + } + evlist_free(&evs); + key_parser_flush(r->ctx->parser, &evs); + for (size_t j = 0; j < evs.len; j++) { + if (reader_dispatch(r, &evs.items[j], evs.raws[j], + evs.lens[j]) != 0) { + evlist_free(&evs); + r->exit = 1; + return NULL; + } + } + evlist_free(&evs); + } + r->exit = 1; + return NULL; +} + +int input_key_reader_start(input_key_reader *r) { + r->stop = 0; + r->exit = 0; + if (pthread_create(&r->tid, NULL, reader_thread, r) != 0) return -1; + r->started = 1; + return 0; +} + +void input_key_reader_stop(input_key_reader *r) { + if (!r->started) return; + r->stop = 1; + /* The thread notices stop within one poll timeout (50 ms). */ + pthread_join(r->tid, NULL); + r->started = 0; +} + +void input_key_reader_free(input_key_reader *r) { + input_key_reader_stop(r); + evlist_free(&r->q); + pthread_mutex_destroy(&r->qlock); + if (r->ctx) { + if (r->ctx->parser) { + key_parser_free(r->ctx->parser); + free(r->ctx->parser); + } + free(r->ctx); + } + free(r); +} + +/* ================================================================== */ +/* Shell shadow */ +/* ================================================================== */ + +char *shell_shadow_apply(editor *shadow, input_key key) { + input_action a; + memset(&a, 0, sizeof(a)); + editor_apply(shadow, key, &a); + if (a.kind == ACT_SUBMIT && a.text) { + size_t n; + xtrim_span(a.text, strlen(a.text), &n); + if (n > 0) { + char *text = a.text; + editor_clear(shadow); + a.text = NULL; + return text; + } + } + input_action_free(&a); + return NULL; +} + +char *input_history_path_for(const char *config_dir) { + size_t n = strlen(config_dir); + char *p = (char *) xmalloc(n + 1 + 14); /* "/llsh/history\0" */ + memcpy(p, config_dir, n); + memcpy(p + n, "/llsh/history\0", 15); + return p; +} + +char *input_default_config_dir(void) { + const char *xdg = getenv("XDG_CONFIG_HOME"); + if (xdg && xdg[0] == '/') return xstrdup(xdg); + const char *home = getenv("HOME"); + if (home && home[0]) { + size_t n = strlen(home); + char *p = (char *) xmalloc(n + 1 + 8); /* "/.config\0" */ + memcpy(p, home, n); + memcpy(p + n, "/.config\0", 9); + return p; + } + return xstrdup("/tmp"); +} + +char *input_default_history_path(void) { + char *dir = input_default_config_dir(); + char *p = input_history_path_for(dir); + free(dir); + return p; +} diff --git a/src/input.h b/src/input.h new file mode 100644 index 0000000..2eee9e1 --- /dev/null +++ b/src/input.h @@ -0,0 +1,253 @@ +#ifndef LLSH_INPUT_H +#define LLSH_INPUT_H + +#include "util.h" +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Keys (decoded) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int kind; + unsigned int cp; /* KEY_CHAR: codepoint */ +} input_key; + +enum { + KEY_CHAR, + KEY_BACKSPACE, + KEY_DELETE, + KEY_LEFT, + KEY_RIGHT, + KEY_UP, + KEY_DOWN, + KEY_HOME, + KEY_END, + KEY_PAGEUP, + KEY_PAGEDOWN, + KEY_ENTER, + KEY_NEWLINE, + KEY_TAB, + KEY_BACKTAB, + KEY_ESC, + KEY_CTRL_A, + KEY_CTRL_C, + KEY_CTRL_D, + KEY_CTRL_E, + KEY_CTRL_K, + KEY_CTRL_L, + KEY_CTRL_U, + KEY_CTRL_W, + KEY_CTRL_Y, + KEY_UNDO, + KEY_UNKNOWN, +}; + +/* ------------------------------------------------------------------ */ +/* Actions */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int kind; + char *text; /* ACT_SUBMIT: owned buffer */ +} input_action; + +enum { + ACT_NONE, + ACT_SUBMIT, + ACT_QUIT, + ACT_STOP, + ACT_TOGGLE_MODE, + ACT_CLEAR_SCREEN, + /* Enter on a trailing backslash: line continuation (newline inserted). */ + ACT_NEWLINE, +}; + +void input_action_free(input_action *a); + +/* ------------------------------------------------------------------ */ +/* Event list (owned) */ +/* ------------------------------------------------------------------ */ + +/* True circular FIFO: pushes/pops advance indices modulo `cap` and never + * shift live elements. */ +typedef struct { + input_key *items; + unsigned char **raws; + size_t *lens; + size_t len; + size_t head; + size_t cap; +} evlist; + +void evlist_init(evlist *l); +void evlist_free(evlist *l); +void evlist_push(evlist *l, input_key k, const unsigned char *raw, size_t raw_len); +/* Remove the oldest event (frees its raw buffer). No-op when empty. */ +void evlist_pop(evlist *l); + +/* ------------------------------------------------------------------ */ +/* Parser */ +/* ------------------------------------------------------------------ */ + +typedef struct { + int state; /* KST_* */ + xbuf seq; /* raw sequence bytes (ESC + rest) */ + xbuf csi_params; + unsigned char utf8[8]; + size_t utf8_len; + bool pasting; /* bracketed paste mode (CSI 200/201) */ +} key_parser; + +enum { KST_GROUND, KST_ESCAPE, KST_CSI, KST_SS3 }; + +void key_parser_init(key_parser *p); +void key_parser_free(key_parser *p); +/* Feed a chunk of terminal bytes; emits events into `out`. */ +size_t key_parser_feed(key_parser *p, const unsigned char *in, size_t n, evlist *out); +/* Drain any partial sequence; emits at most one event. Returns 1 if emitted. */ +int key_parser_flush(key_parser *p, evlist *out); + +/* ------------------------------------------------------------------ */ +/* Editor (multi-line) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *text; + size_t caret; +} editor_undo_entry; + +typedef struct { + char *text; /* owned; lines separated by '\n' */ + size_t caret; /* byte offset, always on a UTF-8 boundary */ + char *killed; /* most recent kill/delete, for Ctrl+Y yank */ + editor_undo_entry *undo; /* bounded undo stack (oldest -> newest) */ + size_t undo_len; + size_t undo_cap; +} editor; + +void editor_init(editor *e); +void editor_free(editor *e); +void editor_clear(editor *e); +void editor_set(editor *e, const char *text); +char *editor_text(const editor *e); +bool editor_is_empty(const editor *e); +/* Number of lines in the buffer (1 when empty). */ +size_t editor_line_count(const editor *e); +/* 0-based index of the line holding the caret. */ +size_t editor_caret_line(const editor *e); +void editor_apply(editor *e, input_key key, input_action *a); + +/* Terminal-wrap geometry for the multi-line input. These let the renderer and + * the tests agree on how a buffer of a given width breaks into physical rows + * and where the caret lands, without touching a tty. The model matches the + * delayed-wrap auto-wrapping used by xterm/VTE. */ +void input_wrap_caret(const char *text, size_t caret, size_t w, + size_t *row, size_t *col); +size_t input_wrap_total(const char *text, size_t w); +/* True when the caret sits in the delayed-wrap pending state: on the last + * column of a row immediately after printing that row's final character. */ +bool input_wrap_pending(const char *text, size_t caret, size_t w); + +/* ------------------------------------------------------------------ */ +/* History */ +/* ------------------------------------------------------------------ */ + +#define LLSH_HISTORY_LIMIT 100 + +/* `entries` is oldest -> newest with a ring head: live entries are + * items[head .. len), and `index` is an absolute position in items. */ +typedef struct { + strlist entries; + size_t head; + bool active; + size_t index; + char *snapshot; + char *path; +} input_history; + +input_history *input_history_new(void); +/* Open history at path; loads existing entries. NULL on error. */ +input_history *input_history_open(const char *path); +void input_history_free(input_history *h); +void input_history_push(input_history *h, const char *text); +void input_history_reset(input_history *h); + +/* Consolidated Up/Down history policy. dir: -1 for Up, +1 for Down. + * caret_line describes the editor so the policy can tell a caret move + * (mid-buffer) from a history recall (from the relevant edge, or mid-walk). + * Returns an owned entry to load (caller frees), or NULL when the press + * should move the caret instead / the walk is exhausted. */ +char *input_history_walk(input_history *h, int dir, size_t caret_line, + const char *current); +bool input_history_persist(input_history *h); + +/* ------------------------------------------------------------------ */ +/* Terminal / reader */ +/* ------------------------------------------------------------------ */ + +/* Enable raw mode + bracketed paste + kitty keyboard protocol. */ +int input_setup_terminal(void); +/* Restore terminal; write teardown sequences. */ +void input_teardown_terminal(void); + +typedef struct input_key_reader input_key_reader; + +typedef struct { + key_parser *parser; + /* Optional direct hook, called for every event in the reader thread. */ + int (*send)(input_key_reader *r, const input_key *k, const unsigned char *raw, + size_t raw_len); +} reader_ctx; + +input_key_reader *input_key_reader_new(reader_ctx *ctx); +int input_key_reader_start(input_key_reader *r); +/* Push an event into the reader's queue (used by tests). */ +void input_key_reader_send(input_key_reader *r, const input_key *k); +/* Push an event with a raw payload into the reader's queue (used by tests). */ +void input_key_reader_send_raw(input_key_reader *r, const input_key *k, + const char *raw); +/* Pop a queued event (non-blocking); false when the queue is empty. + * When `raw` is non-NULL the caller receives an owned copy of the event's + * raw bytes (the queue frees its own slot on pop); release it with + * input_raw_free once consumed. Pass NULL for `raw`/`raw_len` when only the + * decoded key is needed. */ +bool input_key_reader_recv(input_key_reader *r, input_key *out, + const unsigned char **raw, size_t *raw_len); +/* Release a raw buffer returned by input_key_reader_recv. NULL-safe. */ +void input_raw_free(const unsigned char *raw); +void input_key_reader_stop(input_key_reader *r); +void input_key_reader_free(input_key_reader *r); + +/* True when stdin is a terminal (used to gate DSR queries). */ +int input_isatty(void); + +/* ------------------------------------------------------------------ */ +/* Shell shadow */ +/* ------------------------------------------------------------------ */ + +/* Feed one key into the shell-mode shadow editor. If the key finished a + * non-empty command, the shadow is cleared and the submitted text is + * returned (caller frees); otherwise NULL. */ +char *shell_shadow_apply(editor *shadow, input_key key); + +/* History file location: `config_dir/llsh/history`. */ +char *input_history_path_for(const char *config_dir); +/* $XDG_CONFIG_HOME when set to an absolute path, else $HOME/.config, + * else /tmp. */ +char *input_default_config_dir(void); +/* default_history_path = history_path_for(default_config_dir). */ +char *input_default_history_path(void); + +/* ------------------------------------------------------------------ */ +/* Display */ +/* ------------------------------------------------------------------ */ + +/* Display width in terminal columns (ANSI escapes skipped). */ +size_t input_display_len(const char *s); +/* Display width of the first n bytes of s. */ +size_t input_display_len_n(const char *s, size_t n); + +#endif diff --git a/src/limits.cpp b/src/limits.cpp new file mode 100644 index 0000000..bd4b773 --- /dev/null +++ b/src/limits.cpp @@ -0,0 +1,275 @@ +#include "limits.h" + +#include +#include +#include + +#define DEFAULT_SHELL_TIMEOUT_SECS 120UL +#define DEFAULT_MAX_OUTPUT_CHARS 16000UL +#define DEFAULT_MAX_FILE_BYTES 2000000UL + +static tool_limits g_limits; +static bool g_limits_inited = false; +static bool (*g_confirm_hook)(const char *, void *) = NULL; +static void *g_confirm_ud = NULL; + +/* The global starts zeroed in BSS; make sure it holds defaults before + * any consumer reads it (Rust: lazy static initialized to defaults). */ +static void limits_ensure(void) { + if (!g_limits_inited) { + tool_limits_defaults(&g_limits); + g_limits_inited = true; + } +} + +void tool_limits_defaults(tool_limits *l) { + memset(l, 0, sizeof *l); + l->shell_timeout_secs = DEFAULT_SHELL_TIMEOUT_SECS; + l->max_output_chars = DEFAULT_MAX_OUTPUT_CHARS; + l->max_file_bytes = DEFAULT_MAX_FILE_BYTES; + sl_init(&l->shell_allowlist); + sl_init(&l->shell_denylist); + l->confirm_destructive = false; + l->verbose = false; +} + +void tool_limits_set(const tool_limits *l) { + limits_ensure(); + tool_limits_free(&g_limits); + g_limits = *l; + sl_init(&g_limits.shell_allowlist); + sl_init(&g_limits.shell_denylist); + for (size_t i = 0; i < l->shell_allowlist.len; i++) + sl_push(&g_limits.shell_allowlist, xstrdup(l->shell_allowlist.items[i])); + for (size_t i = 0; i < l->shell_denylist.len; i++) + sl_push(&g_limits.shell_denylist, xstrdup(l->shell_denylist.items[i])); +} + +const tool_limits *tool_limits_current(void) { + limits_ensure(); + return &g_limits; +} + +void tool_limits_get(tool_limits *out) { + limits_ensure(); + *out = g_limits; + sl_init(&out->shell_allowlist); + sl_init(&out->shell_denylist); + for (size_t i = 0; i < g_limits.shell_allowlist.len; i++) + sl_push(&out->shell_allowlist, + xstrdup(g_limits.shell_allowlist.items[i])); + for (size_t i = 0; i < g_limits.shell_denylist.len; i++) + sl_push(&out->shell_denylist, xstrdup(g_limits.shell_denylist.items[i])); +} + +void tool_limits_free(tool_limits *l) { + sl_free(&l->shell_allowlist); + sl_free(&l->shell_denylist); +} + +static bool entry_matches(const char *entry_raw, const char *command_raw) { + size_t elen, clen; + const char *entry = xtrim_span(entry_raw, strlen(entry_raw), &elen); + const char *command = xtrim_span(command_raw, strlen(command_raw), &clen); + if (elen == 0) + return true; + if (clen == elen && strncmp(entry, command, elen) == 0) + return true; + /* leading substring on a word boundary */ + if (clen > elen && strncmp(entry, command, elen) == 0) { + char c = command[elen]; + if (c == ' ' || c == '\t') + return true; + } + /* bare first word */ + size_t fw = 0; + while (fw < clen && !isspace((unsigned char)command[fw])) + fw++; + return fw == elen && strncmp(entry, command, elen) == 0; +} + +static bool word_eq(const char *w, size_t n, const char *s); + +/* Return the likely executable after common command wrappers. This is a + * conservative token-level check, not a shell parser. */ +static const char *unwrap_command(const char *command) { + const char *p = command; + for (int depth = 0; depth < 8; depth++) { + while (*p && isspace((unsigned char)*p)) + p++; + const char *word = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t n = (size_t)(p - word); + bool wrapper = word_eq(word, n, "sudo") || word_eq(word, n, "env") || + word_eq(word, n, "command") || word_eq(word, n, "nohup") || + word_eq(word, n, "time") || word_eq(word, n, "nice") || + word_eq(word, n, "busybox"); + if (!wrapper) + return word; + bool skip_next = false; + while (*p) { + while (*p && isspace((unsigned char)*p)) + p++; + if (!*p) + return p; + const char *next = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t nn = (size_t)(p - next); + if (skip_next) { + skip_next = false; + continue; + } + if ((word_eq(word, n, "sudo") || word_eq(word, n, "nice")) && + (word_eq(next, nn, "-u") || word_eq(next, nn, "-g") || + word_eq(next, nn, "-n") || word_eq(next, nn, "-C") || + word_eq(next, nn, "--user") || word_eq(next, nn, "--group"))) { + skip_next = true; + continue; + } + if (next[0] == '-' || + ((word_eq(word, n, "env") || word_eq(word, n, "busybox")) && + strchr(next, '=') != NULL)) + continue; + p = next; + break; + } + } + while (*p && isspace((unsigned char)*p)) + p++; + return p; +} + +int shell_policy(const char *command, const tool_limits *l, char **reason) { + for (size_t i = 0; i < l->shell_denylist.len; i++) { + const char *e = l->shell_denylist.items[i]; + if (entry_matches(e, command) || + entry_matches(e, unwrap_command(command))) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "command matches the denylist entry `%s`", e); + char *r = xbuf_steal(&b); + if (reason) + *reason = r; + else + free(r); + return SHELL_DENIED; + } + } + if (l->shell_allowlist.len > 0) { + bool any = false; + for (size_t i = 0; i < l->shell_allowlist.len; i++) + if (entry_matches(l->shell_allowlist.items[i], command) || + entry_matches(l->shell_allowlist.items[i], + unwrap_command(command))) { + any = true; + break; + } + if (!any) { + xbuf list; + xbuf_init(&list); + for (size_t i = 0; i < l->shell_allowlist.len; i++) { + if (i) + xbuf_put(&list, ", "); + xbuf_put(&list, l->shell_allowlist.items[i]); + } + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "command not in the allowlist (%s)", list.data ? list.data : ""); + xbuf_free(&list); + char *r = xbuf_steal(&b); + if (reason) + *reason = r; + else + free(r); + return SHELL_DENIED; + } + } + return SHELL_ALLOWED; +} + +static bool word_eq(const char *w, size_t n, const char *s) { + size_t sn = strlen(s); + return n == sn && strncmp(w, s, n) == 0; +} + +bool is_destructive(const char *command) { + /* This is deliberately conservative rather than a shell parser: syntax + * that can introduce or expand commands requires confirmation. */ + for (const char *p = command; *p; p++) { + if (*p == ';' || *p == '|' || *p == '&' || *p == '\n' || + *p == '`' || *p == '<' || *p == '>' || *p == '$') + return true; + } + /* split the executable and its first argument after common wrappers */ + const char *p = unwrap_command(command); + while (*p && isspace((unsigned char)*p)) + p++; + const char *w1 = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t w1len = (size_t)(p - w1); + while (*p && isspace((unsigned char)*p)) + p++; + const char *w2 = p; + while (*p && !isspace((unsigned char)*p)) + p++; + size_t w2len = (size_t)(p - w2); + + if (word_eq(w1, w1len, "rm") || word_eq(w1, w1len, "rmdir") || + word_eq(w1, w1len, "kill") || word_eq(w1, w1len, "killall") || + word_eq(w1, w1len, "pkill") || word_eq(w1, w1len, "truncate") || + word_eq(w1, w1len, "mv") || word_eq(w1, w1len, "chmod") || + word_eq(w1, w1len, "chown") || word_eq(w1, w1len, "mkfs") || + word_eq(w1, w1len, "fdisk") || word_eq(w1, w1len, "parted") || + word_eq(w1, w1len, "mount") || word_eq(w1, w1len, "umount") || + word_eq(w1, w1len, "swapon") || word_eq(w1, w1len, "swapoff") || + word_eq(w1, w1len, "shutdown") || word_eq(w1, w1len, "reboot") || + word_eq(w1, w1len, "poweroff") || word_eq(w1, w1len, "halt")) + return true; + if (word_eq(w1, w1len, "dd")) + return strstr(command, "of=") != NULL; + if (word_eq(w1, w1len, "git")) + return word_eq(w2, w2len, "reset") || word_eq(w2, w2len, "clean") || + word_eq(w2, w2len, "checkout") || word_eq(w2, w2len, "rebase") || + word_eq(w2, w2len, "push"); + return false; +} + +void set_confirm_hook(bool (*hook)(const char *, void *), void *ud) { + g_confirm_hook = hook; + g_confirm_ud = ud; +} + +static bool confirm_command(const char *command) { + if (g_confirm_hook) + return g_confirm_hook(command, g_confirm_ud); + return false; +} + +int shell_gate(const char *command, const tool_limits *l, char **err) { + char *reason = NULL; + if (shell_policy(command, l, &reason) == SHELL_DENIED) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "run_shell blocked: %s", reason); + free(reason); + if (err) + *err = xbuf_steal(&b); + return -1; + } + if (l->confirm_destructive && is_destructive(command) && + !confirm_command(command)) { + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "user declined to run destructive command: %s", command); + char *r = xbuf_steal(&b); + if (err) + *err = r; + else + free(r); + return -1; + } + return 0; +} diff --git a/src/limits.h b/src/limits.h new file mode 100644 index 0000000..14ec0e5 --- /dev/null +++ b/src/limits.h @@ -0,0 +1,40 @@ +#ifndef LLSH_LIMITS_H +#define LLSH_LIMITS_H + +#include "util.h" + +/* Process-global tool limits + run_shell policy (mirrors limits.rs). */ + +typedef struct { + unsigned long shell_timeout_secs; + unsigned long max_output_chars; + unsigned long max_file_bytes; + strlist shell_allowlist; + strlist shell_denylist; + bool confirm_destructive; + bool verbose; +} tool_limits; + +void tool_limits_defaults(tool_limits *l); +void tool_limits_set(const tool_limits *l); +/* Read-only view of the current limits (no copy). The pointer is valid until + * the next tool_limits_set; do not store it across set calls. */ +const tool_limits *tool_limits_current(void); +void tool_limits_get(tool_limits *out); +void tool_limits_free(tool_limits *l); + +typedef enum { + SHELL_ALLOWED, + SHELL_DENIED, +} shell_verdict; + +/* Returns 0 allowed, -1 denied (reason in **err, owned). */ +int shell_policy(const char *command, const tool_limits *l, char **reason); +bool is_destructive(const char *command); + +void set_confirm_hook(bool (*hook)(const char *command, void *ud), void *ud); + +/* Full gate: policy, then destructive confirmation. 0 = proceed. */ +int shell_gate(const char *command, const tool_limits *l, char **err); + +#endif diff --git a/src/llm.h b/src/llm.h new file mode 100644 index 0000000..f0b68f0 --- /dev/null +++ b/src/llm.h @@ -0,0 +1,1722 @@ +/* + * llm.h -- header-only LLM provider protocol layer. + * + * Speaks OpenAI-compatible (any /chat/completions endpoint), Anthropic + * (/v1/messages), and Ollama (/api/chat). Pure protocol: request assembly, + * HTTP POST with stop-cancellation, SSE / NDJSON streaming with live delta + * emission, response and error-body parsing, Retry-After handling. No + * application state: messages arrive as borrowed `llm_message` arrays, tools + * as a pre-built `"tools":[...]` JSON fragment, and tool execution, session + * access, and emit formatting are supplied by the caller. + * + * Header-only: every symbol is `static inline`, so a translation unit that + * `#include`s this file gets a self-contained copy and there is nothing to + * link. Include it from exactly the TUs that drive a provider. Each TU's + * copies are independent. + * + * Dependencies: curl, cJSON, libc. + * + * API: + * llm_url / llm_auth / llm_build_request request side + * llm_post / llm_post_stream transport + * llm_parse_body / llm_parse_error / + * llm_header_retry_after response side + * llm_sse_acc_* / llm_sse_parse_line streaming accumulator + * llm_completion / llm_completion_free parsed result + */ +#ifndef LLM_H +#define LLM_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef __cplusplus +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* ------------------------------------------------------------------ */ +/* Provider + connection */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LLM_OPENAI = 0, + LLM_ANTHROPIC, + LLM_OLLAMA, +} llm_provider; + +/* Default cap on generated tokens for Anthropic requests, which require an + * explicit `max_tokens`. */ +#define LLM_DEFAULT_MAX_TOKENS 8192UL + +/* Total wall-clock budget for a single (possibly streaming) LLM request. + * Generous on purpose: long generations and slow cold-start servers. */ +#define LLM_REQUEST_TIMEOUT_SECS 600L + +/* One provider connection. Strings are borrowed for the call's lifetime. + * base_url NULL = provider default; api_key NULL = unauthenticated; + * ca_bundle NULL = curl's default CA path. */ +typedef struct { + llm_provider provider; + const char *model; + const char *base_url; + const char *api_key; + const char *ca_bundle; +} llm_conn; + +/* Optional generation parameters. Every field has an "unset" value, so a + * zeroed struct (or NULL) produces a plain request: + * temperature < 0 = unset + * max_tokens 0 = unset + * reasoning_effort NULL = unset ("low" / "medium" / "high") + * thinking_budget 0 = unset (tokens; Anthropic extended thinking) + * response_schema NULL = unset (JSON Schema text, caller-validated) */ +typedef struct { + double temperature; + unsigned long max_tokens; + const char *reasoning_effort; + unsigned long thinking_budget; + const char *response_schema; +} llm_params; + +static inline void llm_params_init(llm_params *p) { + memset(p, 0, sizeof *p); + p->temperature = -1.0; +} + +/* Anthropic extended-thinking budget for a reasoning-effort label. */ +#define LLM_EFFORT_BUDGET_LOW 1024UL +#define LLM_EFFORT_BUDGET_MEDIUM 4096UL +#define LLM_EFFORT_BUDGET_HIGH 16384UL + +/* ------------------------------------------------------------------ */ +/* Wire message model (borrowed strings) */ +/* ------------------------------------------------------------------ */ + +typedef enum { + LLM_ROLE_SYSTEM, + LLM_ROLE_USER, + LLM_ROLE_ASSISTANT, +} llm_role; + +typedef enum { + LLM_TEXT, /* user/assistant text */ + LLM_REASONING, /* assistant chain of thought (display only, not re-sent) */ + LLM_TOOL_CALL, /* assistant: name, args (JSON text) */ + LLM_TOOL_RESULT, /* user: call (tool-call id), name, text */ +} llm_part_kind; + +typedef struct { + llm_part_kind kind; + const char *text; /* LLM_TEXT / LLM_REASONING / LLM_TOOL_RESULT */ + const char *name; /* LLM_TOOL_CALL / LLM_TOOL_RESULT */ + const char *args; /* LLM_TOOL_CALL: JSON text */ + const char *call; /* LLM_TOOL_RESULT: tool-call id (may be NULL) */ +} llm_part; + +typedef struct { + llm_role role; + llm_part *parts; /* borrowed; const from the consumer's view */ + size_t part_count; +} llm_message; + +/* Growable byte buffer (NUL-terminated on steal). */ +typedef struct llm_buf { + char *data; + size_t len; + size_t cap; +} llm_buf; + +/* ------------------------------------------------------------------ */ +/* Parsed completion (owned strings) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *text; + char *reasoning; /* thinking / reasoning_content, when the model returns it */ + char **tc_id; + char **tc_name; + char **tc_args; + size_t tc_len; + unsigned long input_tokens; +} llm_completion; + +/* Accumulates one streamed response. Text/reasoning arrive as deltas; + * tool calls may arrive in indexed chunks (OpenAI) or as whole blocks + * (Anthropic) or complete objects (Ollama NDJSON). */ +typedef struct { + struct llm_buf text; + struct llm_buf reasoning; + char **tc_id; + char **tc_name; + struct llm_buf *tc_args; /* growing argument fragments, one per call */ + size_t tc_len; + unsigned long input_tokens; + bool done; + /* Anthropic event state: the `event:` line that the next `data:` + * belongs to, and the kind of the open content block. */ + char *anth_event; + int anth_mode; /* 0 = text, 1 = thinking, 2 = tool */ + size_t anth_tc; +} llm_sse_acc; + +/* Live-delta emitter for streaming. Return 0 to continue, non-zero to + * abort the transfer (a user stop). */ +typedef enum { + LLM_EMIT_TEXT = 0, + LLM_EMIT_REASONING, +} llm_emit_kind; + +typedef int (*llm_emit_cb)(llm_provider prov, llm_emit_kind kind, + const char *delta, void *user); + +/* ================================================================== */ +/* Implementation (static inline; active in every including TU) */ +/* ================================================================== */ + +/* ------------------------------------------------------------------ */ +/* Allocation + small string utilities (self-contained) */ +/* ------------------------------------------------------------------ */ + +static inline void *llm_xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "llm: out of memory\n"); + abort(); + } + return p; +} + +static inline void *llm_xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "llm: out of memory\n"); + abort(); + } + return q; +} + +static inline char *llm_strdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *)llm_xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *llm_strndup(const char *s, size_t n) { + char *p = (char *)llm_xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +static inline char *llm_vsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return llm_strdup(""); + char *p = (char *)llm_xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +static inline char *llm_strfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = llm_vsnprintf(fmt, ap); + va_end(ap); + return s; +} + +static inline bool llm_streq(const char *a, const char *b) { + return a && b && strcmp(a, b) == 0; +} + +/* First case-insensitive occurrence of `needle` in `hay` (portable + * strcasestr). */ +static inline const char *llm_strcasestr(const char *hay, const char *needle) { + size_t nn = strlen(needle); + if (nn == 0) + return hay; + for (const char *p = hay; *p; p++) { + bool ok = true; + for (size_t i = 0; i < nn; i++) { + if (tolower((unsigned char)p[i]) != + tolower((unsigned char)needle[i])) { + ok = false; + break; + } + } + if (ok) + return p; + } + return NULL; +} + +static inline void llm_buf_init(llm_buf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void llm_buf_free(llm_buf *b) { + free(b->data); + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +static inline void llm_buf_reserve(llm_buf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *)llm_xrealloc(b->data, cap); + b->cap = cap; + } +} + +static inline void llm_buf_putn(llm_buf *b, const char *s, size_t n) { + llm_buf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +static inline void llm_buf_put(llm_buf *b, const char *s) { + if (s) + llm_buf_putn(b, s, strlen(s)); +} + +static inline void llm_buf_push(llm_buf *b, char c) { + llm_buf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +static inline char *llm_buf_steal(llm_buf *b) { + if (!b->data) + b->data = (char *)llm_xmalloc(1); + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = 0; + b->cap = 0; + return s; +} + +/* Small owned string list. */ +typedef struct { + char **items; + size_t len; + size_t cap; +} llm_strlist; + +static inline void llm_sl_init(llm_strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +static inline void llm_sl_free(llm_strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + llm_sl_init(l); +} + +static inline void llm_sl_push_copy(llm_strlist *l, const char *s) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = + (char **)llm_xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = llm_strdup(s); +} + +/* ------------------------------------------------------------------ */ +/* Request side */ +/* ------------------------------------------------------------------ */ + +static inline char *llm_url(const llm_conn *c) { + const char *def; + switch (c->provider) { + case LLM_ANTHROPIC: + def = "https://api.anthropic.com"; + break; + case LLM_OLLAMA: + def = "http://localhost:11434"; + break; + case LLM_OPENAI: + default: + def = "https://api.openai.com/v1"; + break; + } + char *base = llm_strdup(c->base_url ? c->base_url : def); + size_t n = strlen(base); + while (n && base[n - 1] == '/') + base[--n] = '\0'; + llm_buf u; + llm_buf_init(&u); + llm_buf_put(&u, base); + free(base); + switch (c->provider) { + case LLM_ANTHROPIC: + llm_buf_put(&u, "/v1/messages"); + break; + case LLM_OLLAMA: + llm_buf_put(&u, "/api/chat"); + break; + case LLM_OPENAI: + default: + llm_buf_put(&u, "/chat/completions"); + break; + } + char *out = llm_buf_steal(&u); + llm_buf_free(&u); + return out; +} + +static inline void llm_auth(const llm_conn *c, char **header, char **value) { + if (header) + *header = NULL; + if (value) + *value = NULL; + if (!c->api_key) + return; + switch (c->provider) { + case LLM_OPENAI: + if (header) + *header = llm_strdup("Authorization"); + if (value) + *value = llm_strfmt("Bearer %s", c->api_key); + break; + case LLM_ANTHROPIC: + if (header) + *header = llm_strdup("x-api-key"); + if (value) + *value = llm_strdup(c->api_key); + break; + case LLM_OLLAMA: + break; + } +} + +/* Shared setup: cached-or-throwaway curl handle, the JSON + auth header + * list, and the common POST options. Returns the handle (or NULL with + * *err set) and the header list in *out_h (the caller frees it with + * curl_slist_free_all). */ +static inline CURL *llm_post_setup(const llm_conn *c, const char *url, + const char *body, + struct curl_slist **out_h, char **err, + CURL **cached) { + CURL *h = cached ? *cached : NULL; + if (!h) { + h = curl_easy_init(); + if (h && cached) + *cached = h; + } + if (!h) { + if (err) + *err = llm_strdup("could not initialize curl"); + return NULL; + } + struct curl_slist *hl = NULL; + hl = curl_slist_append(hl, "Content-Type: application/json"); + char *ah = NULL, *av = NULL; + llm_auth(c, &ah, &av); + if (ah && av) { + llm_buf hb; + llm_buf_init(&hb); + char *combo = llm_strfmt("%s: %s", ah, av); + llm_buf_put(&hb, combo); + free(combo); + hl = curl_slist_append(hl, hb.data); + llm_buf_free(&hb); + free(ah); + free(av); + } + curl_easy_setopt(h, CURLOPT_HTTPHEADER, hl); + curl_easy_setopt(h, CURLOPT_URL, url); + curl_easy_setopt(h, CURLOPT_POST, 1L); + curl_easy_setopt(h, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(h, CURLOPT_POSTFIELDSIZE, (long)strlen(body)); + curl_easy_setopt(h, CURLOPT_TIMEOUT, LLM_REQUEST_TIMEOUT_SECS); + curl_easy_setopt(h, CURLOPT_NOSIGNAL, 1L); + if (c->ca_bundle) + curl_easy_setopt(h, CURLOPT_CAINFO, c->ca_bundle); + *out_h = hl; + return h; +} + +typedef struct { + llm_buf body; + llm_buf headers; + std::atomic *stop; + bool response_too_large; +} llm_post_ctx; + +static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) { + llm_post_ctx *c = (llm_post_ctx *)ud; + const size_t cap = 8u * 1024u * 1024u; + if ((s && n > SIZE_MAX / s) || c->body.len > cap || + s * n > cap - c->body.len) { + c->response_too_large = true; + return 0; + } + llm_buf_putn(&c->body, d, s * n); + return s * n; +} + +static inline size_t llm_post_header(char *d, size_t s, size_t n, void *ud) { + llm_post_ctx *c = (llm_post_ctx *)ud; + llm_buf_putn(&c->headers, d, s * n); + return s * n; +} + +static inline int llm_post_xfer(void *clientp, curl_off_t t, curl_off_t dn, + curl_off_t ut, curl_off_t un) { + (void)t; + (void)dn; + (void)ut; + (void)un; + llm_post_ctx *c = (llm_post_ctx *)clientp; + if (c->stop && c->stop->load()) + return 1; /* abort the transfer */ + return 0; +} + +static inline int llm_post(const llm_conn *c, const char *url, + const char *body, long *status_out, char **body_out, + char **headers_out, char **err, + std::atomic *stop, CURL **cached) { + if (body_out) + *body_out = NULL; + if (headers_out) + *headers_out = NULL; + if (err) + *err = NULL; + + llm_post_ctx ctx; + llm_buf_init(&ctx.body); + llm_buf_init(&ctx.headers); + ctx.stop = stop; + ctx.response_too_large = false; + + struct curl_slist *h = NULL; + CURL *curl = + llm_post_setup(c, url, body, &h, err, cached); + if (!curl) { + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + return -1; + } + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_post_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx.body); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_post_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_post_xfer); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx); + + CURLcode rc = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(h); + char *body_str = llm_buf_steal(&ctx.body); + char *hdr_str = llm_buf_steal(&ctx.headers); + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + if (!cached || *cached != curl) + curl_easy_cleanup(curl); /* throwaway handle; the cached one outlives us */ + + if (rc != CURLE_OK) { + if (err) + *err = llm_strdup(ctx.response_too_large + ? "LLM response exceeds 8 MiB limit" + : curl_easy_strerror(rc)); + free(body_str); + free(hdr_str); + return -1; + } + if (status_out) + *status_out = code; + if (body_out) + *body_out = body_str; + else + free(body_str); + if (headers_out) + *headers_out = hdr_str; + else + free(hdr_str); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Wire messages -> provider JSON */ +/* ------------------------------------------------------------------ */ + +/* Recover the tool-call ids in order (they live on the tool results). */ +static inline void llm_collect_result_ids(const llm_message *msgs, + size_t n, llm_strlist *out) { + llm_sl_init(out); + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role != LLM_ROLE_USER) + continue; + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TOOL_RESULT) + llm_sl_push_copy(out, p->call ? p->call : ""); + } + } +} + +static inline char *llm_id_or(const llm_strlist *ids, size_t i) { + if (i < ids->len && ids->items[i] && ids->items[i][0]) + return llm_strdup(ids->items[i]); + return llm_strfmt("call-%d", (int)(i + 1)); +} + +static inline cJSON *llm_openai_messages(const llm_message *msgs, size_t n) { + cJSON *arr = cJSON_CreateArray(); + llm_strlist ids; + llm_collect_result_ids(msgs, n, &ids); + size_t call_idx = 0, result_idx = 0; + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role == LLM_ROLE_SYSTEM) { + const char *text = ""; + if (m->part_count && m->parts[0].text) + text = m->parts[0].text; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "system"); + cJSON_AddStringToObject(o, "content", text); + cJSON_AddItemToArray(arr, o); + } else if (m->role == LLM_ROLE_USER) { + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "user"); + cJSON_AddStringToObject(o, "content", + p->text ? p->text : ""); + cJSON_AddItemToArray(arr, o); + } else if (p->kind == LLM_TOOL_RESULT) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "tool"); + char *id = llm_id_or(&ids, result_idx); + result_idx++; + cJSON_AddStringToObject(o, "tool_call_id", id); + free(id); + if (p->name) + cJSON_AddStringToObject(o, "name", p->name); + cJSON_AddStringToObject(o, "content", + p->text ? p->text : ""); + cJSON_AddItemToArray(arr, o); + } + } + } else { /* assistant */ + llm_buf txt; + llm_buf_init(&txt); + bool first = true; + cJSON *tcs = NULL; + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + if (!first) + llm_buf_push(&txt, '\n'); + if (p->text) + llm_buf_put(&txt, p->text); + first = false; + } else if (p->kind == LLM_TOOL_CALL) { + if (!tcs) + tcs = cJSON_CreateArray(); + cJSON *tcobj = cJSON_CreateObject(); + char *id = llm_id_or(&ids, call_idx); + cJSON_AddStringToObject(tcobj, "id", id); + free(id); + cJSON_AddStringToObject(tcobj, "type", "function"); + cJSON *fn = cJSON_AddObjectToObject(tcobj, "function"); + cJSON_AddStringToObject(fn, "name", p->name ? p->name : ""); + cJSON_AddStringToObject(fn, "arguments", p->args ? p->args : "{}"); + cJSON_AddItemToArray(tcs, tcobj); + call_idx++; + } + } + char *t = llm_buf_steal(&txt); + llm_buf_free(&txt); + if (t[0] || tcs) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "role", "assistant"); + if (t[0]) + cJSON_AddStringToObject(o, "content", t); + else + cJSON_AddItemToObject(o, "content", cJSON_CreateNull()); + if (tcs) + cJSON_AddItemToObject(o, "tool_calls", tcs); + cJSON_AddItemToArray(arr, o); + } + free(t); + } + } + llm_sl_free(&ids); + return arr; +} + +static inline cJSON *llm_anthropic_messages(const llm_message *msgs, + size_t n) { + cJSON *arr = cJSON_CreateArray(); + llm_strlist ids; + llm_collect_result_ids(msgs, n, &ids); + size_t call_idx = 0, result_idx = 0; + for (size_t i = 0; i < n; i++) { + const llm_message *m = &msgs[i]; + if (m->role == LLM_ROLE_SYSTEM) + continue; /* folded into the top-level `system` */ + cJSON *o = cJSON_CreateObject(); + cJSON *content = cJSON_CreateArray(); + if (m->role == LLM_ROLE_USER) { + cJSON_AddStringToObject(o, "role", "user"); + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "text"); + cJSON_AddStringToObject(b, "text", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } else if (p->kind == LLM_TOOL_RESULT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "tool_result"); + char *id = llm_id_or(&ids, result_idx); + result_idx++; + cJSON_AddStringToObject(b, "tool_use_id", id); + free(id); + cJSON_AddStringToObject(b, "content", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } + } + } else { /* assistant */ + cJSON_AddStringToObject(o, "role", "assistant"); + for (size_t j = 0; j < m->part_count; j++) { + const llm_part *p = &m->parts[j]; + if (p->kind == LLM_TEXT) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "text"); + cJSON_AddStringToObject(b, "text", p->text ? p->text : ""); + cJSON_AddItemToArray(content, b); + } else if (p->kind == LLM_TOOL_CALL) { + cJSON *b = cJSON_CreateObject(); + cJSON_AddStringToObject(b, "type", "tool_use"); + char *id = llm_id_or(&ids, call_idx); + cJSON_AddStringToObject(b, "id", id); + free(id); + cJSON_AddStringToObject(b, "name", p->name ? p->name : ""); + cJSON *inp = p->args ? cJSON_Parse(p->args) : NULL; + if (!inp) + inp = cJSON_CreateObject(); + cJSON_AddItemToObject(b, "input", inp); + cJSON_AddItemToArray(content, b); + call_idx++; + } + } + } + cJSON_AddItemToObject(o, "content", content); + cJSON_AddItemToArray(arr, o); + } + llm_sl_free(&ids); + return arr; +} + +/* ------------------------------------------------------------------ */ +/* Response parsing */ +/* ------------------------------------------------------------------ */ + +static inline void llm_completion_free(llm_completion *c) { + free(c->text); + free(c->reasoning); + for (size_t i = 0; i < c->tc_len; i++) { + free(c->tc_id[i]); + free(c->tc_name[i]); + free(c->tc_args[i]); + } + free(c->tc_id); + free(c->tc_name); + free(c->tc_args); + c->text = NULL; + c->reasoning = NULL; + c->tc_id = c->tc_name = c->tc_args = NULL; + c->tc_len = 0; + c->input_tokens = 0; +} + +static inline void llm_add_tool_call(llm_completion *c, char *id, char *name, + char *args) { + c->tc_id = (char **)llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id); + c->tc_name = (char **)llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name); + c->tc_args = (char **)llm_xrealloc(c->tc_args, (c->tc_len + 1) * sizeof *c->tc_args); + c->tc_id[c->tc_len] = id; + c->tc_name[c->tc_len] = name; + c->tc_args[c->tc_len] = args; + c->tc_len++; +} + +static inline void llm_parse_openai(const cJSON *resp, llm_completion *c) { + const cJSON *choices = cJSON_GetObjectItem(resp, "choices"); + if (cJSON_IsArray(choices) && choices->child) { + const cJSON *choice = choices->child; + const cJSON *msg = cJSON_GetObjectItem(choice, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring) + c->text = llm_strdup(ct->valuestring); + /* Reasoning models (o1/o3 and compatibles) return the chain of + * thought in `reasoning_content`; some servers use `reasoning`. */ + const cJSON *rc = cJSON_GetObjectItem(msg, "reasoning_content"); + if (!cJSON_IsString(rc) || !rc->valuestring) + rc = cJSON_GetObjectItem(msg, "reasoning"); + if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0]) + c->reasoning = llm_strdup(rc->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + char *id = NULL, *name = NULL, *args = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + args = llm_strdup(ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + args = s ? s : llm_strdup("{}"); + } + } + llm_add_tool_call( + c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)), + name ? name : llm_strdup(""), + args ? args : llm_strdup("{}")); + } + } + } + } + const cJSON *usage = cJSON_GetObjectItem(resp, "usage"); + if (usage) { + const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens"); + if (cJSON_IsNumber(pt)) + c->input_tokens = (unsigned long)pt->valuedouble; + } +} + +static inline void llm_parse_anthropic(const cJSON *resp, llm_completion *c) { + const cJSON *content = cJSON_GetObjectItem(resp, "content"); + if (cJSON_IsArray(content)) { + const cJSON *b = NULL; + cJSON_ArrayForEach(b, content) { + const cJSON *type = cJSON_GetObjectItem(b, "type"); + const char *t = (cJSON_IsString(type) && type->valuestring) + ? type->valuestring + : ""; + if (llm_streq(t, "text")) { + const cJSON *txt = cJSON_GetObjectItem(b, "text"); + if (cJSON_IsString(txt) && txt->valuestring) { + free(c->text); + c->text = llm_strdup(txt->valuestring); + } + } else if (llm_streq(t, "thinking")) { + const cJSON *th = cJSON_GetObjectItem(b, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) { + free(c->reasoning); + c->reasoning = llm_strdup(th->valuestring); + } + } else if (llm_streq(t, "tool_use")) { + char *id = NULL, *name = NULL, *args = NULL; + const cJSON *tid = cJSON_GetObjectItem(b, "id"); + if (cJSON_IsString(tid) && tid->valuestring) + id = llm_strdup(tid->valuestring); + const cJSON *tn = cJSON_GetObjectItem(b, "name"); + if (cJSON_IsString(tn) && tn->valuestring) + name = llm_strdup(tn->valuestring); + const cJSON *tin = cJSON_GetObjectItem(b, "input"); + if (cJSON_IsObject(tin)) { + char *s = cJSON_PrintUnformatted(tin); + args = s ? s : llm_strdup("{}"); + } + llm_add_tool_call( + c, id ? id : llm_strfmt("call-%d", (int)(c->tc_len + 1)), + name ? name : llm_strdup(""), + args ? args : llm_strdup("{}")); + } + } + } + const cJSON *usage = cJSON_GetObjectItem(resp, "usage"); + if (usage) { + const cJSON *it = cJSON_GetObjectItem(usage, "input_tokens"); + if (cJSON_IsNumber(it)) + c->input_tokens = (unsigned long)it->valuedouble; + } +} + +static inline void llm_parse_ollama(const cJSON *resp, llm_completion *c) { + const cJSON *msg = cJSON_GetObjectItem(resp, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring) + c->text = llm_strdup(ct->valuestring); + /* Thinking models (qwen3, deepseek-r1, …) return `message.thinking`. */ + const cJSON *th = cJSON_GetObjectItem(msg, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) + c->reasoning = llm_strdup(th->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + const cJSON *id = cJSON_GetObjectItem(tc, "id"); + char *cid = (cJSON_IsString(id) && id->valuestring) + ? llm_strdup(id->valuestring) + : llm_strfmt("call-%d", (int)(c->tc_len + 1)); + char *name = llm_strdup(""), *args = llm_strdup("{}"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) { + free(name); + name = llm_strdup(cn->valuestring); + } + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) { + free(args); + args = llm_strdup(ca->valuestring); + } else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + free(args); + args = s; + } + } + } + llm_add_tool_call(c, cid, name, args); + } + } + } + const cJSON *pe = cJSON_GetObjectItem(resp, "prompt_eval_count"); + if (cJSON_IsNumber(pe)) + c->input_tokens = (unsigned long)pe->valuedouble; +} + +static inline int llm_parse_body(const llm_conn *c, const char *body, + llm_completion *out) { + if (!body || !body[0]) + return -1; + cJSON *resp = cJSON_Parse(body); + if (!resp) + return -1; + if (c->provider == LLM_ANTHROPIC) + llm_parse_anthropic(resp, out); + else if (c->provider == LLM_OLLAMA) + llm_parse_ollama(resp, out); + else + llm_parse_openai(resp, out); + cJSON_Delete(resp); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Error body + Retry-After */ +/* ------------------------------------------------------------------ */ + +static inline char *llm_parse_error(const char *body, long status) { + char *msg = NULL; + if (body && body[0]) { + cJSON *j = cJSON_Parse(body); + if (j) { + const cJSON *e = cJSON_GetObjectItem(j, "error"); + const cJSON *em = cJSON_IsObject(e) + ? cJSON_GetObjectItem(e, "message") + : (cJSON_IsString(e) ? e : NULL); + if (cJSON_IsString(em) && em->valuestring) + msg = llm_strdup(em->valuestring); + if (!msg) + msg = llm_strdup(""); + cJSON_Delete(j); + } else { + size_t cap = strlen(body); + if (cap > 500) + cap = 500; + msg = llm_strndup(body, cap); + } + } + if (!msg || !msg[0]) + msg = llm_strfmt("HTTP %ld", status); + return msg; +} + +static inline unsigned long llm_retry_after(const char *val) { + if (!val) + return 0; + char *end = NULL; + long v = strtol(val, &end, 10); + if (end != val && v >= 0 && v <= 300) + return (unsigned long)v; + /* HTTP-date (RFC 7231 §7.1.1.1): a GMT timestamp. Take the delta from + * now, clamped to the same [0, 300]s window as delta-seconds. */ + struct tm t; + memset(&t, 0, sizeof t); + static const char *const fmts[] = { + "%a, %d %b %Y %H:%M:%S GMT", /* IMF-fixdate */ + "%a, %d %b %Y %H:%M:%S %Z", /* tolerant tz name */ + "%a %b %d %H:%M:%S %Y", /* obs-date / asctime */ + }; + for (size_t i = 0; i < sizeof fmts / sizeof *fmts; i++) { + struct tm tt = t; + if (strptime(val, fmts[i], &tt) != NULL) { + long delta = (long)(timegm(&tt) - time(NULL)); + if (delta < 0) + delta = 0; + if (delta > 300) + delta = 300; + return (unsigned long)delta; + } + } + return 0; +} + +static inline unsigned long llm_header_retry_after(const char *headers) { + if (!headers) + return 0; + const char *p = headers; + while ((p = llm_strcasestr(p, "retry-after")) != NULL) { + p += strlen("retry-after"); + while (*p == ':' || *p == ' ' || *p == '\t') + p++; + /* find the value up to CRLF */ + char *val = llm_strdup(p); + size_t n = strlen(val); + while (n && (val[n - 1] == '\r' || val[n - 1] == '\n')) + val[--n] = '\0'; + unsigned long r = llm_retry_after(val); + free(val); + if (r) + return r; + p += 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* SSE streaming */ +/* ------------------------------------------------------------------ */ + +static inline void llm_sse_acc_init(llm_sse_acc *a, llm_provider prov) { + (void)prov; + memset(a, 0, sizeof *a); + llm_buf_init(&a->text); + llm_buf_init(&a->reasoning); +} + +static inline void llm_sse_acc_free(llm_sse_acc *a) { + llm_buf_free(&a->text); + llm_buf_free(&a->reasoning); + for (size_t i = 0; i < a->tc_len; i++) { + free(a->tc_id[i]); + free(a->tc_name[i]); + llm_buf_free(&a->tc_args[i]); + } + free(a->tc_id); + free(a->tc_name); + free(a->tc_args); + free(a->anth_event); + memset(a, 0, sizeof *a); +} + +static inline void llm_acc_push_tool(llm_sse_acc *a, char *id, char *name) { + a->tc_id = (char **)llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id); + a->tc_name = + (char **)llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name); + a->tc_args = + (llm_buf *)llm_xrealloc(a->tc_args, (a->tc_len + 1) * sizeof *a->tc_args); + a->tc_id[a->tc_len] = id; + a->tc_name[a->tc_len] = name; + llm_buf_init(&a->tc_args[a->tc_len]); + a->tc_len++; +} + +/* The args buffer of the tool call at `idx` (creating the slot when the + * stream has not announced it yet). Fresh slots adopt `id`/`name`. */ +static inline llm_buf *llm_acc_tool_args(llm_sse_acc *a, size_t idx, char *id, + char *name) { + while (a->tc_len <= idx) + llm_acc_push_tool(a, NULL, NULL); + if (id) { + free(a->tc_id[idx]); + a->tc_id[idx] = id; + } + if (name) { + if (a->tc_name[idx] && a->tc_name[idx][0]) + free(name); + else { + free(a->tc_name[idx]); + a->tc_name[idx] = name; + } + } + return &a->tc_args[idx]; +} + +static inline void llm_sse_parse_line(llm_provider prov, const char *line, + llm_sse_acc *a) { + const char *p = line; + while (*p == ' ' || *p == '\t') + p++; + if (!*p) + return; + + if (prov == LLM_ANTHROPIC) { + if (strncmp(p, "event:", 6) == 0) { + const char *v = p + 6; + while (*v == ' ') + v++; + char *ev = llm_strdup(v); + size_t n = strlen(ev); + while (n && (ev[n - 1] == '\r' || ev[n - 1] == '\n')) + ev[--n] = '\0'; + free(a->anth_event); + a->anth_event = ev; + return; + } + if (strncmp(p, "data:", 5) != 0) + return; + const char *v = p + 5; + while (*v == ' ') + v++; + const char *ev = a->anth_event ? a->anth_event : ""; + /* message_stop arrives as `data: [DONE]` (not JSON). */ + if (llm_streq(ev, "message_stop")) { + a->done = true; + return; + } + cJSON *j = cJSON_Parse(v); + if (!j) + return; + if (llm_streq(ev, "message_start")) { + const cJSON *m = cJSON_GetObjectItem(j, "message"); + const cJSON *u = m ? cJSON_GetObjectItem(m, "usage") : NULL; + if (u) { + const cJSON *it = cJSON_GetObjectItem(u, "input_tokens"); + if (cJSON_IsNumber(it)) + a->input_tokens = (unsigned long)it->valuedouble; + } + } else if (llm_streq(ev, "content_block_start")) { + const cJSON *cb = cJSON_GetObjectItem(j, "content_block"); + const cJSON *t = cb ? cJSON_GetObjectItem(cb, "type") : NULL; + if (cJSON_IsString(t) && t->valuestring) { + if (llm_streq(t->valuestring, "thinking")) { + a->anth_mode = 1; + } else if (llm_streq(t->valuestring, "tool_use")) { + char *id = NULL, *name = NULL; + const cJSON *tid = cJSON_GetObjectItem(cb, "id"); + if (cJSON_IsString(tid) && tid->valuestring) + id = llm_strdup(tid->valuestring); + const cJSON *tn = cJSON_GetObjectItem(cb, "name"); + if (cJSON_IsString(tn) && tn->valuestring) + name = llm_strdup(tn->valuestring); + llm_acc_push_tool(a, id, name); + a->anth_mode = 2; + a->anth_tc = a->tc_len - 1; + } else { + a->anth_mode = 0; + } + } + } else if (llm_streq(ev, "content_block_delta")) { + const cJSON *d = cJSON_GetObjectItem(j, "delta"); + const cJSON *t = d ? cJSON_GetObjectItem(d, "type") : NULL; + if (cJSON_IsString(t) && t->valuestring) { + if (llm_streq(t->valuestring, "text_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "text"); + if (cJSON_IsString(s) && s->valuestring) + llm_buf_put(&a->text, s->valuestring); + } else if (llm_streq(t->valuestring, "thinking_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "thinking"); + if (cJSON_IsString(s) && s->valuestring) + llm_buf_put(&a->reasoning, s->valuestring); + } else if (llm_streq(t->valuestring, "input_json_delta")) { + const cJSON *s = cJSON_GetObjectItem(d, "partial_json"); + if (cJSON_IsString(s) && s->valuestring && a->anth_mode == 2) + llm_buf_put(&a->tc_args[a->anth_tc], s->valuestring); + } + } + } + cJSON_Delete(j); + return; + } + + if (prov == LLM_OLLAMA) { + cJSON *j = cJSON_Parse(p); + if (!j) + return; + const cJSON *msg = cJSON_GetObjectItem(j, "message"); + if (msg) { + const cJSON *ct = cJSON_GetObjectItem(msg, "content"); + if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0]) + llm_buf_put(&a->text, ct->valuestring); + const cJSON *th = cJSON_GetObjectItem(msg, "thinking"); + if (cJSON_IsString(th) && th->valuestring && th->valuestring[0]) + llm_buf_put(&a->reasoning, th->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(msg, "tool_calls"); + if (cJSON_IsArray(tcs)) { + /* Ollama carries no per-call index (and often no id), so the + * array position is the only stable key: map the i-th call to + * accumulator slot i so arguments streamed across chunks + * accumulate into one call instead of appending a duplicate + * slot per chunk (the OpenAI path does this via `index`). */ + size_t pos = 0; + const cJSON *tc = tcs->child; + while (tc) { + char *id = NULL, *name = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + } + llm_buf *ab = llm_acc_tool_args(a, pos, id, name); + if (fn) { + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + llm_buf_put(ab, ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + llm_buf_put(ab, s); + free(s); + } + } + } + tc = tc->next; + pos++; + } + } + } + const cJSON *pe = cJSON_GetObjectItem(j, "prompt_eval_count"); + if (cJSON_IsNumber(pe)) + a->input_tokens = (unsigned long)pe->valuedouble; + const cJSON *dn = cJSON_GetObjectItem(j, "done"); + if (cJSON_IsBool(dn) && dn->valueint) + a->done = true; + cJSON_Delete(j); + return; + } + + /* OpenAI-compatible SSE. */ + if (strncmp(p, "data:", 5) != 0) + return; + const char *v = p + 5; + while (*v == ' ') + v++; + if (llm_streq(v, "[DONE]")) { + a->done = true; + return; + } + cJSON *j = cJSON_Parse(v); + if (!j) + return; + const cJSON *choices = cJSON_GetObjectItem(j, "choices"); + if (cJSON_IsArray(choices) && choices->child) { + const cJSON *choice = choices->child; + const cJSON *delta = cJSON_GetObjectItem(choice, "delta"); + if (delta) { + const cJSON *ct = cJSON_GetObjectItem(delta, "content"); + if (cJSON_IsString(ct) && ct->valuestring && ct->valuestring[0]) + llm_buf_put(&a->text, ct->valuestring); + const cJSON *rc = cJSON_GetObjectItem(delta, "reasoning_content"); + if (!cJSON_IsString(rc) || !rc->valuestring) + rc = cJSON_GetObjectItem(delta, "reasoning"); + if (cJSON_IsString(rc) && rc->valuestring && rc->valuestring[0]) + llm_buf_put(&a->reasoning, rc->valuestring); + const cJSON *tcs = cJSON_GetObjectItem(delta, "tool_calls"); + if (cJSON_IsArray(tcs)) { + const cJSON *tc = NULL; + cJSON_ArrayForEach(tc, tcs) { + size_t idx = 0; + const cJSON *ci = cJSON_GetObjectItem(tc, "index"); + if (cJSON_IsNumber(ci)) + idx = (size_t)ci->valuedouble; + char *id = NULL, *name = NULL; + const cJSON *cid = cJSON_GetObjectItem(tc, "id"); + if (cJSON_IsString(cid) && cid->valuestring) + id = llm_strdup(cid->valuestring); + const cJSON *fn = cJSON_GetObjectItem(tc, "function"); + if (fn) { + const cJSON *cn = cJSON_GetObjectItem(fn, "name"); + if (cJSON_IsString(cn) && cn->valuestring) + name = llm_strdup(cn->valuestring); + } + llm_buf *ab = llm_acc_tool_args(a, idx, id, name); + if (fn) { + const cJSON *ca = cJSON_GetObjectItem(fn, "arguments"); + if (cJSON_IsString(ca) && ca->valuestring) + llm_buf_put(ab, ca->valuestring); + else if (cJSON_IsObject(ca)) { + char *s = cJSON_PrintUnformatted(ca); + if (s) { + llm_buf_put(ab, s); + free(s); + } + } + } + } + } + const cJSON *fr = cJSON_GetObjectItem(choice, "finish_reason"); + if (cJSON_IsString(fr) && fr->valuestring && fr->valuestring[0]) + a->done = true; + } + } + const cJSON *usage = cJSON_GetObjectItem(j, "usage"); + if (usage) { + const cJSON *pt = cJSON_GetObjectItem(usage, "prompt_tokens"); + if (cJSON_IsNumber(pt)) + a->input_tokens = (unsigned long)pt->valuedouble; + } + cJSON_Delete(j); +} + +static inline bool llm_sse_acc_had_content(const llm_sse_acc *a) { + return a->text.len > 0 || a->reasoning.len > 0 || a->tc_len > 0 || a->done; +} + +/* Materialize the accumulated stream into `c` (ownership transfers). */ +static inline void llm_sse_acc_into(llm_sse_acc *a, llm_completion *c) { + /* Ownership transfers to `c` when non-empty; only free the buffer when + * `c` did not take it (empty). Freeing unconditionally would dangle the + * pointer `c` now owns -> double-free in llm_completion_free. */ + char *t = llm_buf_steal(&a->text); + if (t && t[0]) + c->text = t; + else + free(t); + char *r = llm_buf_steal(&a->reasoning); + if (r && r[0]) + c->reasoning = r; + else + free(r); + for (size_t i = 0; i < a->tc_len; i++) { + char *args = llm_buf_steal(&a->tc_args[i]); + char *id = a->tc_id[i] ? a->tc_id[i] + : llm_strfmt("call-%d", (int)(i + 1)); + char *name = a->tc_name[i] ? a->tc_name[i] : llm_strdup(""); + if (!args || !args[0]) { + free(args); + args = llm_strdup("{}"); + } + llm_add_tool_call(c, id, name, args); + a->tc_id[i] = a->tc_name[i] = NULL; + } + c->input_tokens = a->input_tokens; + a->input_tokens = 0; +} + +/* Write-callback state for a streaming POST. */ +typedef struct { + llm_buf body; + llm_buf headers; + llm_buf linebuf; /* bytes not yet terminated by a newline */ + llm_sse_acc *acc; + llm_provider prov; + llm_emit_cb emit; + void *user; + std::atomic *stop; + bool stopped; /* set when the transfer should abort */ + bool response_too_large; + size_t emitted_text; + size_t emitted_reasoning; +} llm_stream_ctx; + +static inline size_t llm_stream_write(char *d, size_t s, size_t n, void *ud) { + llm_stream_ctx *c = (llm_stream_ctx *)ud; + const size_t cap = 8u * 1024u * 1024u; + if ((s && n > SIZE_MAX / s) || c->body.len > cap || + s * n > cap - c->body.len || c->linebuf.len > cap - s * n) { + c->response_too_large = true; + return 0; + } + llm_buf_putn(&c->body, d, s * n); + llm_buf_putn(&c->linebuf, d, s * n); + for (;;) { + char *nl = (char *)memchr(c->linebuf.data, '\n', c->linebuf.len); + if (!nl) + break; + size_t lpos = (size_t)(nl - c->linebuf.data); + if (lpos > 0 && c->linebuf.data[lpos - 1] == '\r') + lpos--; + char *line = llm_strndup(c->linebuf.data, lpos); + size_t rest = c->linebuf.len - lpos - 1; + memmove(c->linebuf.data, c->linebuf.data + lpos + 1, rest); + c->linebuf.len = rest; + llm_sse_parse_line(c->prov, line, c->acc); + free(line); + + if (c->acc->text.len > c->emitted_text) { + char *delta = llm_strndup(c->acc->text.data + c->emitted_text, + c->acc->text.len - c->emitted_text); + c->emitted_text = c->acc->text.len; + if (c->emit && + c->emit(c->prov, LLM_EMIT_TEXT, delta, c->user) != 0) + c->stopped = true; + free(delta); + } + if (!c->stopped && c->acc->reasoning.len > c->emitted_reasoning) { + char *delta = llm_strndup(c->acc->reasoning.data + + c->emitted_reasoning, + c->acc->reasoning.len - + c->emitted_reasoning); + c->emitted_reasoning = c->acc->reasoning.len; + if (c->emit && + c->emit(c->prov, LLM_EMIT_REASONING, delta, c->user) != 0) + c->stopped = true; + free(delta); + } + if (c->stopped || (c->stop && c->stop->load())) { + c->stopped = true; + break; + } + } + if (c->stopped) + return 0; /* short write: abort the transfer */ + return s * n; +} + +static inline size_t llm_stream_header(char *d, size_t s, size_t n, void *ud) { + llm_stream_ctx *c = (llm_stream_ctx *)ud; + llm_buf_putn(&c->headers, d, s * n); + return s * n; +} + +static inline int llm_stream_xfer(void *clientp, curl_off_t t, curl_off_t dn, + curl_off_t ut, curl_off_t un) { + (void)t; + (void)dn; + (void)ut; + (void)un; + llm_stream_ctx *c = (llm_stream_ctx *)clientp; + if (c->stop && c->stop->load()) + return 1; + return 0; +} + +static inline int llm_post_stream(const llm_conn *c, const char *url, + const char *body, long *status_out, + char **body_out, char **headers_out, + llm_sse_acc *acc, llm_emit_cb emit, void *user, + std::atomic *stop, bool *stopped, + char **err, CURL **cached) { + if (body_out) + *body_out = NULL; + if (headers_out) + *headers_out = NULL; + if (stopped) + *stopped = false; + if (err) + *err = NULL; + if (status_out) + *status_out = 0; + + llm_stream_ctx ctx; + memset(&ctx, 0, sizeof ctx); + llm_buf_init(&ctx.body); + llm_buf_init(&ctx.headers); + llm_buf_init(&ctx.linebuf); + ctx.acc = acc; + ctx.prov = c->provider; + ctx.emit = emit; + ctx.user = user; + ctx.stop = stop; + + struct curl_slist *h = NULL; + CURL *curl = llm_post_setup(c, url, body, &h, err, cached); + if (!curl) { + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + llm_buf_free(&ctx.linebuf); + return -1; + } + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, llm_stream_write); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, llm_stream_header); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, &ctx); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, llm_stream_xfer); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &ctx); + + CURLcode rc = curl_easy_perform(curl); + long code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + curl_slist_free_all(h); + char *body_str = llm_buf_steal(&ctx.body); + char *hdr_str = llm_buf_steal(&ctx.headers); + llm_buf_free(&ctx.body); + llm_buf_free(&ctx.headers); + llm_buf_free(&ctx.linebuf); + if (!cached || *cached != curl) + curl_easy_cleanup(curl); + + if (ctx.stopped) { + if (stopped) + *stopped = true; + free(body_str); + free(hdr_str); + return 0; + } + if (rc != CURLE_OK) { + if (err) + *err = llm_strdup(ctx.response_too_large + ? "LLM streaming response exceeds 8 MiB limit" + : curl_easy_strerror(rc)); + free(body_str); + free(hdr_str); + return -1; + } + if (status_out) + *status_out = code; + if (body_out) + *body_out = body_str; + else + free(body_str); + if (headers_out) + *headers_out = hdr_str; + else + free(hdr_str); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Request assembly */ +/* ------------------------------------------------------------------ */ + +/* JSON-encode an arbitrary string (NULL-safe). */ +static inline char *llm_json_str(const char *s) { + cJSON *j = cJSON_CreateString(s ? s : ""); + char *r = cJSON_PrintUnformatted(j); + cJSON_Delete(j); + return r ? r : llm_strdup("\"\""); +} + +/* OpenAI-compatible body (OpenAI + Ollama). `top_level_system` routes the + * preamble to a top-level `system` field (Ollama) instead of a leading + * system message (OpenAI). `p` may be NULL. */ +static inline void llm_emit_openai_body(llm_buf *req, const llm_conn *c, + const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, + bool top_level_system, bool stream, + const llm_params *p) { + llm_buf_put(req, "{\"model\":"); + char *model = llm_json_str(c->model); + llm_buf_put(req, model); + free(model); + llm_buf_put(req, ",\"stream\":"); + llm_buf_put(req, stream ? "true" : "false"); + llm_buf_push(req, ','); + if (top_level_system && preamble && preamble[0]) { + char *ps = llm_json_str(preamble); + llm_buf_put(req, "\"system\":"); + llm_buf_put(req, ps); + free(ps); + llm_buf_push(req, ','); + } + llm_buf_put(req, "\"messages\":"); + cJSON *om = llm_openai_messages(msgs, n); + if (!top_level_system && preamble && preamble[0]) { + /* OpenAI-compatible: the preamble becomes a leading system message. */ + cJSON *sys = cJSON_CreateObject(); + cJSON_AddStringToObject(sys, "role", "system"); + cJSON_AddStringToObject(sys, "content", preamble); + cJSON_InsertItemInArray(om, 0, sys); + } + char *oms = cJSON_PrintUnformatted(om); + llm_buf_put(req, oms ? oms : "[]"); + free(oms); + cJSON_Delete(om); + if (tools_json) { + llm_buf_push(req, ','); + llm_buf_put(req, tools_json); + } + if (p) { + if (top_level_system) { + /* Ollama: sampling knobs nest under `options`. */ + if (p->temperature >= 0.0 || p->max_tokens > 0) { + llm_buf_put(req, ",\"options\":{"); + bool first = true; + if (p->temperature >= 0.0) { + char *t = llm_strfmt("\"temperature\":%.6g", + p->temperature); + llm_buf_put(req, t); + free(t); + first = false; + } + if (p->max_tokens > 0) { + if (!first) + llm_buf_push(req, ','); + char *v = llm_strfmt("\"num_predict\":%lu", + p->max_tokens); + llm_buf_put(req, v); + free(v); + } + llm_buf_push(req, '}'); + } + if (p->reasoning_effort) + llm_buf_put(req, ",\"think\":true"); + if (p->response_schema && p->response_schema[0]) { + llm_buf_put(req, ",\"format\":"); + llm_buf_put(req, p->response_schema); + } + } else { + if (p->temperature >= 0.0) { + char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature); + llm_buf_put(req, t); + free(t); + } + if (p->max_tokens > 0) { + char *v = llm_strfmt(",\"max_tokens\":%lu", p->max_tokens); + llm_buf_put(req, v); + free(v); + } + if (p->reasoning_effort) { + char *e = llm_strfmt(",\"reasoning_effort\":\"%s\"", + p->reasoning_effort); + llm_buf_put(req, e); + free(e); + } + if (p->response_schema && p->response_schema[0]) { + llm_buf_put(req, + ",\"response_format\":{\"type\":\"json_schema\"," + "\"json_schema\":{\"name\":\"response\"," + "\"schema\":"); + llm_buf_put(req, p->response_schema); + llm_buf_put(req, "}}"); + } + } + } + llm_buf_push(req, '}'); +} + +/* Assemble a request body. `p` may be NULL for a plain request. */ +static inline char *llm_build_request_ex(const llm_conn *c, + const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, bool stream, + const llm_params *p) { + llm_buf req; + llm_buf_init(&req); + if (c->provider == LLM_ANTHROPIC) { + unsigned long budget = p ? p->thinking_budget : 0; + if (budget == 0 && p && p->reasoning_effort) { + if (llm_streq(p->reasoning_effort, "low")) + budget = LLM_EFFORT_BUDGET_LOW; + else if (llm_streq(p->reasoning_effort, "medium")) + budget = LLM_EFFORT_BUDGET_MEDIUM; + else + budget = LLM_EFFORT_BUDGET_HIGH; + } + unsigned long mt = LLM_DEFAULT_MAX_TOKENS; + if (p && p->max_tokens > 0) + mt = p->max_tokens; + if (budget && mt <= budget) + mt = budget + 1024; + llm_buf_put(&req, "{\"model\":"); + char *model = llm_json_str(c->model); + llm_buf_put(&req, model); + free(model); + { + char *mtb = llm_strfmt(",\"max_tokens\":%lu,\"stream\":", mt); + llm_buf_put(&req, mtb); + free(mtb); + } + llm_buf_put(&req, stream ? "true" : "false"); + if (budget) { + /* Extended thinking; Anthropic pins temperature to 1 with it. */ + char *th = llm_strfmt( + ",\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":%lu}", + budget); + llm_buf_put(&req, th); + free(th); + } else if (p && p->temperature >= 0.0) { + char *t = llm_strfmt(",\"temperature\":%.6g", p->temperature); + llm_buf_put(&req, t); + free(t); + } + llm_buf_push(&req, ','); + const char *sys = preamble; + char *schema_sys = NULL; + if (p && p->response_schema && p->response_schema[0]) { + /* Anthropic has no response_format: steer via the system prompt. */ + schema_sys = llm_strfmt( + "%s\n\nRespond with a single JSON object that matches this " + "JSON Schema. Output nothing else.\n%s", + preamble ? preamble : "", p->response_schema); + sys = schema_sys; + } + if (sys && sys[0]) { + char *ps = llm_json_str(sys); + llm_buf_put(&req, "\"system\":"); + llm_buf_put(&req, ps); + free(ps); + llm_buf_push(&req, ','); + } + free(schema_sys); + llm_buf_put(&req, "\"messages\":"); + cJSON *m = llm_anthropic_messages(msgs, n); + char *ms = cJSON_PrintUnformatted(m); + llm_buf_put(&req, ms ? ms : "[]"); + free(ms); + cJSON_Delete(m); + if (tools_json) { + llm_buf_push(&req, ','); + llm_buf_put(&req, tools_json); + } + llm_buf_push(&req, '}'); + } else if (c->provider == LLM_OLLAMA) { + /* Ollama takes the system prompt as a top-level `system` field. */ + llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, true, + stream, p); + } else { + llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false, + stream, p); + } + char *out = llm_buf_steal(&req); + llm_buf_free(&req); + return out; +} + +static inline char *llm_build_request(const llm_conn *c, const char *preamble, + const llm_message *msgs, size_t n, + const char *tools_json, bool stream) { + return llm_build_request_ex(c, preamble, msgs, n, tools_json, stream, NULL); +} + +#ifdef __cplusplus +} +#endif + +#endif /* LLM_H */ diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..b1247ee --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,1902 @@ +/* llsh — a terminal LLM agent harness (C port). */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "escape.h" +#include "provider.h" +#include "agent.h" +#include "compact.h" +#include "config.h" +#include "dotagents.h" +#include "goal.h" +#include "input.h" +#include "limits.h" +#include "mcp.h" +#include "message.h" +#include "session.h" +#include "shell.h" +#include "tools.h" +#include "verbose.h" + +/* ------------------------------------------------------------------ */ +/* Terminal escapes (from escape.h; faint `2m` has no escape.h macro) */ +/* ------------------------------------------------------------------ */ +#define DIM ESC_DIM +#define BOLD_C ESC_BOLD ESC_COLOR_CYAN +#define RED ESC_COLOR_RED +#define YELLOW ESC_COLOR_YELLOW +#define RESET ESC_RESET +#define CHAT_PROMPT BOLD_C "\xE2\x80\xBA" RESET " " + +/* ------------------------------------------------------------------ */ +/* Output helpers */ +/* ------------------------------------------------------------------ */ + +static void out_str(const char *s) { + if (!s) + return; + fwrite(s, 1, strlen(s), stdout); + fflush(stdout); +} + +static void out_fmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = xvsnprintf(fmt, ap); + va_end(ap); + out_str(s); + free(s); +} + +/* ------------------------------------------------------------------ */ +/* Emit callback (model output -> terminal) */ +/* ------------------------------------------------------------------ */ + +/* ------------------------------------------------------------------ */ +/* Typewriter pacer (ported from ../llsh src/session.rs) */ +/* */ +/* llm.h already streams: provider_stream calls the sess_emit_cb for */ +/* each TEXT/REASONING delta as it arrives. The pacer consumes those */ +/* emits (via emit_cb) into a queue and reveals them at an adaptive */ +/* pace, so a token burst -- or a non-streaming provider that returns */ +/* the whole body at once -- renders as a smooth typewriter instead of a */ +/* single dump. A verbatim/tool line flushes the buffered prose first. */ +/* ------------------------------------------------------------------ */ + +enum { PSTYLE_NORMAL, PSTYLE_DIM }; +enum { PSEG_TEXT, PSEG_VERBATIM }; + +/* Wake every TICK_MS; reveal ~backlog/SMOOTH_MS of the backlog each tick so + * the reveal tracks the model's speed, clamped to [MIN,MAX] chars/tick. */ +#define PACER_TICK_MS 16 +#define PACER_SMOOTH_MS 1000 +#define PACER_MIN 2 +#define PACER_MAX 24 + +typedef struct { + int kind; /* PSEG_TEXT or PSEG_VERBATIM */ + int style; /* PSTYLE_* (TEXT only) */ + char *text; /* owned */ + size_t pos; /* revealed bytes (TEXT only) */ +} pseg; + +typedef struct { + pseg *items; + size_t n, cap; + bool finished; + bool abort; + pthread_t th; + pthread_mutex_t mu; + pthread_cond_t cv; +} pacer; + +static void *pacer_run(void *arg); + +static pacer *pacer_new(void) { + pacer *p = (pacer *) xmalloc(sizeof *p); + memset(p, 0, sizeof *p); + pthread_mutex_init(&p->mu, NULL); + /* Use a monotonic clock so the tick timeout is immune to wall-clock + * adjustments (NTP steps); pthread_cond_timedwait must use the same + * clock as the absolute timeout we compute in pacer_run. */ + pthread_condattr_t ca; + pthread_condattr_init(&ca); + pthread_condattr_setclock(&ca, CLOCK_MONOTONIC); + pthread_cond_init(&p->cv, &ca); + pthread_condattr_destroy(&ca); + return p; +} + +static void pacer_free(pacer *p) { + pthread_mutex_lock(&p->mu); + for (size_t i = 0; i < p->n; i++) + free(p->items[i].text); + free(p->items); + p->n = 0; + pthread_mutex_unlock(&p->mu); + pthread_mutex_destroy(&p->mu); + pthread_cond_destroy(&p->cv); + free(p); +} + +static void pacer_shift(pacer *p) { + memmove(p->items, p->items + 1, (p->n - 1) * sizeof *p->items); + p->n--; +} + +/* Producer side (called from the turn worker via emit_cb). */ +static void pacer_push(pacer *p, int kind, int style, char *owned) { + pthread_mutex_lock(&p->mu); + if (p->n == p->cap) { + p->cap = p->cap ? p->cap * 2 : 8; + p->items = (pseg *) xrealloc(p->items, p->cap * sizeof *p->items); + } + p->items[p->n].kind = kind; + p->items[p->n].style = style; + p->items[p->n].text = owned; + p->items[p->n].pos = 0; + p->n++; + /* No condvar signal: a push must not wake the consumer. Reveal is strictly + * tick-paced (see pacer_run); waking here would dump MAX chars per arriving + * delta instead of spreading it across the smoothing window. */ + pthread_mutex_unlock(&p->mu); +} + +static void pacer_emit_text(pacer *p, int style, const char *text) { + if (!text || !text[0]) + return; + pacer_push(p, PSEG_TEXT, style, xstrdup(text)); +} + +static void pacer_emit_verbatim(pacer *p, const char *text) { + if (!text) + return; + pacer_push(p, PSEG_VERBATIM, PSTYLE_NORMAL, xstrdup(text)); +} + +static void pacer_finish(pacer *p) { + pthread_mutex_lock(&p->mu); + p->finished = true; + pthread_cond_broadcast(&p->cv); + pthread_mutex_unlock(&p->mu); +} + +static void pacer_abort(pacer *p) { + pthread_mutex_lock(&p->mu); + p->abort = true; + pthread_cond_broadcast(&p->cv); + pthread_mutex_unlock(&p->mu); +} + +static void pacer_start(pacer *p) { + pthread_create(&p->th, NULL, pacer_run, p); +} + +static const char *pstyle_transition(int open, int to) { + if (open == PSTYLE_NORMAL && to == PSTYLE_DIM) + return DIM; + if (open == PSTYLE_DIM && to == PSTYLE_NORMAL) + return RESET; + return ""; +} + +static size_t pacer_backlog(const pacer *p) { + size_t b = 0; + for (size_t i = 0; i < p->n; i++) + if (p->items[i].kind == PSEG_TEXT) + b += strlen(p->items[i].text) - p->items[i].pos; + return b; +} + +static size_t pacer_reveal_count(const pacer *p) { + size_t backlog = pacer_backlog(p); + if (backlog == 0) + return 0; + size_t prop = backlog * PACER_TICK_MS / PACER_SMOOTH_MS; + if (prop < PACER_MIN) + prop = PACER_MIN; + if (prop > PACER_MAX) + prop = PACER_MAX; + return prop; +} + +/* Reveal up to `budget` chars of TEXT from the front, writing style + * transitions. Caller holds p->mu. */ +static void pacer_drain_text(pacer *p, int *open, size_t budget) { + size_t remaining = budget; + xbuf out; + xbuf_init(&out); + while (remaining > 0 && p->n > 0) { + pseg *s = &p->items[0]; + if (s->kind != PSEG_TEXT) + break; + size_t len = strlen(s->text); + size_t left = len - s->pos; + if (left == 0) { + free(s->text); + pacer_shift(p); + continue; + } + size_t take = remaining < left ? remaining : left; + if (*open == PSTYLE_DIM && s->style == PSTYLE_NORMAL) { + xbuf_put(&out, RESET "\n\n"); + } else { + xbuf_put(&out, pstyle_transition(*open, s->style)); + } + *open = s->style; + xbuf_putn(&out, s->text + s->pos, take); + s->pos += take; + remaining -= take; + if (s->pos >= len) { + free(s->text); + pacer_shift(p); + } + } + if (out.len) + out_str(out.data); + xbuf_free(&out); +} + +/* Consumer thread: reveal the queue at the adaptive pace, write verbatims + * (flushing preceding prose), and shut down on finish/abort. */ +static void *pacer_run(void *arg) { + pacer *p = (pacer *) arg; + int open = PSTYLE_NORMAL; + for (;;) { + // Tick: wait up to PACER_TICK_MS, or wake early on a new emit/control. + pthread_mutex_lock(&p->mu); + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + ts.tv_nsec += (long)PACER_TICK_MS * 1000000L; + if (ts.tv_nsec >= 1000000000L) { + ts.tv_sec += 1; + ts.tv_nsec -= 1000000000L; + } + pthread_cond_timedwait(&p->cv, &p->mu, &ts); + pthread_mutex_unlock(&p->mu); + + pthread_mutex_lock(&p->mu); + + if (p->abort) { + for (size_t i = 0; i < p->n; i++) + free(p->items[i].text); + p->n = 0; + xbuf o; + xbuf_init(&o); + xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL)); + xbuf_put(&o, "\n"); + xbuf_put(&o, DIM); + xbuf_put(&o, "[stopped]"); + xbuf_put(&o, RESET); + xbuf_put(&o, "\n"); + out_str(o.data ? o.data : ""); + xbuf_free(&o); + open = PSTYLE_NORMAL; + pthread_mutex_unlock(&p->mu); + break; + } + + if (p->n > 0 && p->items[0].kind == PSEG_VERBATIM) { + // Preceding prose is already revealed; write the line immediately. + xbuf o; + xbuf_init(&o); + xbuf_put(&o, pstyle_transition(open, PSTYLE_NORMAL)); + xbuf_put(&o, p->items[0].text); + out_str(o.data ? o.data : ""); + xbuf_free(&o); + open = PSTYLE_NORMAL; + free(p->items[0].text); + pacer_shift(p); + pthread_mutex_unlock(&p->mu); + continue; + } + + if (p->n > 0 && p->items[0].kind == PSEG_TEXT) { + size_t budget = pacer_reveal_count(p); + if (budget) + pacer_drain_text(p, &open, budget); + } + + bool done = false; + if (p->finished && p->n == 0) { + out_str(pstyle_transition(open, PSTYLE_NORMAL)); + out_str("\n"); + open = PSTYLE_NORMAL; + done = true; + } + pthread_mutex_unlock(&p->mu); + + if (done) + break; + } + return NULL; +} + +static int emit_cb(const sess_emit *e, void *user) { + pacer *p = (pacer *) user; + if (!e || !p) + return 0; + switch (e->kind) { + case SESS_EMIT_TEXT: + pacer_emit_text(p, PSTYLE_NORMAL, e->text); + break; + case SESS_EMIT_REASONING: + pacer_emit_text(p, PSTYLE_DIM, e->text); + break; + case SESS_EMIT_VERBATIM: + pacer_emit_verbatim(p, e->text); + break; + case SESS_EMIT_RESULT: + if (e->from_shell) { + char *v = xstrfmt(DIM "%s" RESET, e->text); + pacer_emit_verbatim(p, v); + free(v); + } else { + pacer_emit_verbatim(p, e->text); + } + break; + case SESS_EMIT_STATUS: + break; /* no inline output */ + } + return 0; +} + +/* Run one turn with the pacer active: start it, feed emits through emit_cb, + * then drain (or, if the user stopped, clear + note it) and join. */ +static int run_turn_paced(session *s, const char *prompt, + std::atomic *stop, char **err, int *result) { + pacer *p = pacer_new(); + pacer_start(p); + int rc = session_run_turn(s, prompt, stop, emit_cb, p, err, result); + if (result && *result == SESS_TURN_STOPPED) + pacer_abort(p); + else + pacer_finish(p); + pthread_join(p->th, NULL); + pacer_free(p); + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Destructive-command confirmation hook */ +/* ------------------------------------------------------------------ */ + +static bool interactive_confirm(const char *command, void *ud) { + input_key_reader *reader = (input_key_reader *) ud; + fprintf(stderr, "\n[confirm] run destructive command `%s`? [y/N] ", + command ? command : ""); + fflush(stderr); + if (!reader) + return true; /* non-interactive: allow */ + for (int i = 0; i < 200; i++) { + input_key key; + if (!input_key_reader_recv(reader, &key, NULL, NULL)) { + sleep_ms(20); + continue; + } + if (key.kind == KEY_CHAR) { + fputc((char)key.cp, stderr); + fputc('\n', stderr); + if (key.cp == 'y' || key.cp == 'Y') + return true; + if (key.cp == 'n' || key.cp == 'N') + return false; + } else if (key.kind == KEY_ENTER) { + fputc('\n', stderr); + return false; + } else if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C) { + fputc('\n', stderr); + return false; + } + } + return false; /* timeout: deny destructive commands by default */ +} + +/* ------------------------------------------------------------------ */ +/* /tools, /agents, /tasks, /memories listing */ +/* ------------------------------------------------------------------ */ + +static void list_tools(void) { + const tool_entry *entries = NULL; + size_t n = tool_list_entries(&entries); + for (size_t i = 0; i < n; i++) + out_fmt(" %-12s %s\n", entries[i].name, entries[i].desc); +} + +static void list_agents(const dotagents *dot, session *s) { + char *cur = session_profile_name(s); + out_fmt("current sub-agent: %s\n", cur ? cur : "default (main agent)"); + free(cur); + for (size_t i = 0; i < dot->sub_agent_count; i++) { + const sub_agent *a = &dot->sub_agents[i]; + if (!a->enabled) + continue; + char *cur2 = session_profile_name(s); + bool active = (cur2 && xstreq(cur2, a->id)); + free(cur2); + out_fmt(" %c %s — %s\n", active ? '*' : ' ', a->id, + a->description ? a->description : ""); + } + if (dot->sub_agent_count == 0) + out_str(" (none defined in .agents/agents/)\n"); +} + +static void list_tasks(const dotagents *dot) { + if (dot->task_count == 0) { + out_str("no repeat tasks defined (.agents/tasks//task.md)\n"); + return; + } + for (size_t i = 0; i < dot->task_count; i++) { + const task_entry *t = &dot->tasks[i]; + char *iv = t->has_interval ? itoa_dup((long long)t->interval_minutes) + : xstrdup("?"); + out_fmt(" %s %6s min %s — %s%s\n", t->enabled ? "on " : "off", iv, + t->id, t->name ? t->name : "", + t->run_on_startup ? " (runs at startup)" : ""); + free(iv); + } +} + +static void list_memories(const dotagents *dot) { + if (dot->memory_count == 0) { + out_str("no memories defined (.agents/memories/*.md)\n"); + return; + } + for (size_t i = 0; i < dot->memory_count; i++) { + const memory *m = &dot->memories[i]; + xbuf tags; + xbuf_init(&tags); + for (size_t j = 0; j < m->tag_count; j++) { + if (j) + xbuf_put(&tags, ", "); + xbuf_put(&tags, m->tags[j]); + } + out_fmt(" %-8s %s — %s", m->importance ? m->importance : "", m->id, + m->title ? m->title : ""); + if (m->tag_count && tags.data) + out_fmt(" [%s]", tags.data); + out_str("\n"); + xbuf_free(&tags); + } +} + +/* ------------------------------------------------------------------ */ +/* Turn worker (so ESC / Ctrl+C can stop it) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + session *s; + char *prompt; + std::atomic *stop; + char *err; + int result; + std::atomic done; +} turn_ctx; + +static void *turn_thread(void *arg) { + turn_ctx *tc = (turn_ctx *) arg; + char *err = NULL; + int result = SESS_TURN_COMPLETED; + int rc = run_turn_paced(tc->s, tc->prompt, tc->stop, &err, &result); + if (err) + tc->err = err; + tc->result = result; + tc->done.store(true); + (void)rc; + return NULL; +} + +static int run_turn_interactive(session *s, const char *prompt, + input_key_reader *reader) { + std::atomic stop(false); + turn_ctx tc = {s, xstrdup(prompt), &stop, NULL, SESS_TURN_COMPLETED, false}; + pthread_t th; + if (pthread_create(&th, NULL, turn_thread, &tc) != 0) { + char *err = NULL; + int result = 0; + int rc = run_turn_paced(s, prompt, &stop, &err, &result); + if (rc < 0 && err) { + out_fmt(RED "error: %s" RESET "\n", err); + free(err); + } + free(tc.prompt); + return result; + } + while (!tc.done.load()) { + input_key key; + if (input_key_reader_recv(reader, &key, NULL, NULL)) { + if (key.kind == KEY_ESC || key.kind == KEY_CTRL_C) + stop.store(true); /* the pacer prints [stopped] when it drains */ + } else { + sleep_ms(25); + } + } + pthread_join(th, NULL); + if (tc.err) { + out_fmt(RED "error: %s" RESET "\n", tc.err); + free(tc.err); + } + free(tc.prompt); + return tc.result; +} + +/* ------------------------------------------------------------------ */ +/* /goal */ +/* ------------------------------------------------------------------ */ + +static void report_goal(session *s) { + const goal_report *r = session_goal_report(s); + if (r) { + out_fmt("last goal (%s): \"%s\" — %lu of %lu attempt(s), ~%llu input " + "tokens\n", + r->met ? "met" : "not met", r->criteria, r->turns_done, + r->max_tries, (unsigned long long)r->tokens_used); + } else { + out_str("no /goal run yet this session\n"); + } +} + +static goal_report *make_report(const goal_spec *spec, unsigned long turns, + unsigned long long tokens, bool met) { + goal_report *rep = (goal_report *) xmalloc(sizeof *rep); + rep->criteria = xstrdup(spec->criteria); + rep->max_tries = spec->max_tries; + rep->turns_done = turns; + rep->tokens_used = tokens; + rep->met = met; + return rep; +} + +static void run_goal(session *s, const goal_spec *spec, + input_key_reader *reader) { + char *feedback = NULL; + unsigned long long tokens_used = 0; + for (unsigned long attempt = 1; attempt <= spec->max_tries; attempt++) { + out_fmt("\n" DIM "── goal attempt %lu/%lu ──" RESET "\n", attempt, + spec->max_tries); + char *prompt = goal_prompt(spec, attempt, feedback); + unsigned long before = session_context_tokens(s); + int result = run_turn_interactive(s, prompt, reader); + free(prompt); + unsigned long after = session_context_tokens(s); + /* Accumulate the tokens added by this attempt (context delta), not + * the running context total. */ + if (after > before) + tokens_used += after - before; + if (result == SESS_TURN_STOPPED) { + session_set_goal_report(s, + make_report(spec, attempt, tokens_used, + false)); + out_fmt("goal stopped by the user on attempt %lu\n", attempt); + free(feedback); + return; + } + char *last = session_last_assistant_text(s); + goal_verdict v = {false, NULL}; + char *err = NULL; + int rc = session_evaluate_goal(s, spec, last ? last : "", &v, &err); + free(last); + if (rc != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "evaluating goal"); + free(err); + free(v.reason); + free(feedback); + return; + } + out_fmt(DIM "reviewer: %s — %s" RESET "\n", v.met ? "MET" : "NOT MET", + v.reason ? v.reason : ""); + if (v.met) { + session_set_goal_report(s, + make_report(spec, attempt, tokens_used, + true)); + out_fmt("goal met after %lu attempt(s)\n", attempt); + free(v.reason); + free(feedback); + return; + } + free(feedback); + feedback = v.reason; + v.reason = NULL; + } + session_set_goal_report(s, + make_report(spec, spec->max_tries, tokens_used, + false)); + out_fmt("goal not met after %lu attempt(s); try again with a higher cap\n", + spec->max_tries); + free(feedback); +} + +/* ------------------------------------------------------------------ */ +/* Commands (returns true when the session should end) */ +/* ------------------------------------------------------------------ */ + +/* Banner (defined below); reprinted inline on a model/agent switch. The + * provider-reported context window is adopted before the banner is printed, + * so it is shown correctly the first time. */ +static void banner_print(session *s, const dotagents *dot); +static void banner_worker_join(void); +static int banner_refresh_start(session *s); + +static bool handle_command(session *s, const dotagents *dot, const char *cmd, + input_key_reader *reader) { + const char *p = cmd; + while (*p == ' ' || *p == '\t') + p++; + const char *head_start = p; + while (*p && *p != ' ' && *p != '\t') + p++; + size_t head_len = (size_t)(p - head_start); + char *head = xstrndup(head_start, head_len); + const char *arg = p; + while (*arg == ' ' || *arg == '\t') + arg++; + + if (xstreq(head, "exit") || xstreq(head, "quit")) { + free(head); + return true; + } + if (xstreq(head, "clear")) { + session_clear_history(s); + out_str(ESC_CURSOR_POSITION(1, 1) ESC_CLEAR_SCREEN "\x1b[3J"); + free(head); + return false; + } + if (xstreq(head, "undo")) { + int removed = session_undo_last_turn(s); + if (removed < 0) + out_str("nothing to undo (no user turn in context)\n"); + else + out_fmt("rolled back the last turn (%d message(s) removed from " + "context)\n", + removed); + free(head); + return false; + } + if (xstreq(head, "compact")) { + compact_report report; + memset(&report, 0, sizeof report); + char *err = NULL; + int rc = session_compact_if_needed(s, true, &report, &err); + if (rc < 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "compacting"); + free(err); + } else if (rc == 1) { + session_print_compact(&report); + if (session_context_limit(s) == 0) + out_str("note: no context window is known, so automatic " + "compaction stays off (set `context_limit` to enable " + "it)\n"); + } else { + if (session_context_limit(s) == 0) + out_str("nothing to compact yet; automatic compaction is " + "also off because no context window is known (set " + "`context_limit` to enable it)\n"); + else + out_str("nothing to compact yet\n"); + } + free(head); + return false; + } + if (xstreq(head, "save")) { + const char *path = arg[0] ? arg : "/tmp/llsh-session.json"; + char *err = NULL; + size_t count = session_message_count(s); + if (session_save(s, path, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "saving"); + free(err); + } else { + out_fmt("saved %zu message(s) to %s — resume with: llsh " + "--resume %s\n", + count, path, path); + } + free(head); + return false; + } + if (xstreq(head, "export")) { + const char *path = arg[0] ? arg : "llsh-transcript.md"; + char *md = session_transcript_markdown(s); + char *err = NULL; + if (write_file_from_buf(path, md, strlen(md), &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "exporting"); + free(err); + } else { + out_fmt("exported %zu message(s) to %s\n", + session_message_count(s), path); + } + free(md); + free(head); + return false; + } + if (xstreq(head, "goal")) { + if (arg[0] == '\0') { + report_goal(s); + } else { + goal_spec spec; + char *err = NULL; + if (goal_parse(arg, &spec, &err) != 1) { + out_fmt(RED "error: %s" RESET "\n", err ? err : "parsing goal"); + free(err); + free(head); + return false; + } + out_fmt("goal: %s — up to %lu try(ies)\n", spec.criteria, + spec.max_tries); + run_goal(s, &spec, reader); + goal_spec_free(&spec); + } + free(head); + return false; + } + if (xstreq(head, "model")) { + if (arg[0] == '\0') { + char *m = session_model(s); + out_fmt("current model: %s\n", m); + free(m); + } else { + char *err = NULL; + if (session_set_model(s, arg, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", + err ? err : "switching model"); + free(err); + } else { + banner_refresh_start(s); + out_fmt("model set to %s\n", arg); + /* Reprint the banner: the refresh above has landed, so the + * `model:` and `context window:` lines both reflect the new + * selection. */ + banner_print(s, dot); + } + } + free(head); + return false; + } + if (xstreq(head, "tools")) { + list_tools(); + free(head); + return false; + } + if (xstreq(head, "agent")) { + if (arg[0] == '\0') { + list_agents(dot, s); + } else if (xstreq(arg, "default")) { + char *err = NULL; + session_set_profile(s, NULL, &err); + if (err) { + out_fmt(RED "error: %s" RESET "\n", err); + free(err); + } else { + out_str("sub-agent reset to default (main agent)\n"); + banner_print(s, dot); + } + } else { + char *err = NULL; + if (session_set_profile(s, arg, &err) != 0) { + out_fmt(RED "error: %s" RESET "\n", + err ? err : "switching agent"); + free(err); + } else { + out_fmt("sub-agent set to %s\n", arg); + /* Reprint the banner so the `sub-agent:` line reflects the + * new selection immediately. */ + banner_print(s, dot); + } + } + free(head); + return false; + } + if (xstreq(head, "agents")) { + if (dot->found_count == 0) { + out_str("no .agents/ found (looked for .agents and ~/.agents)\n"); + } else { + for (size_t i = 0; i < dot->found_count; i++) + out_fmt(" %s: %s\n", layer_display(dot->found_layers[i]), + dot->found_paths[i]); + out_fmt(" system-prompt.md: %s\n" + " agents.md: %s\n" + " models.json: %zu preset(s)%s\n" + " mcp.json: %zu server(s)\n" + " skills: %zu\n" + " sub-agents: %zu\n" + " tasks: %zu\n" + " memories: %zu\n", + dot->system_prompt ? "yes" : "no", + dot->agents_md ? "yes" : "no", + dot->has_models ? dot->models.preset_count : 0, + (dot->has_models && dot->models.default_name) + ? xstrfmt(" (default: %s)", + dot->models.default_name) + : "", + dot->mcp_count, dot->skill_count, dot->sub_agent_count, + dot->task_count, dot->memory_count); + } + for (size_t i = 0; i < dot->warnings.len; i++) + out_fmt(" warning: %s\n", dot->warnings.items[i]); + free(head); + return false; + } + if (xstreq(head, "tasks")) { + list_tasks(dot); + free(head); + return false; + } + if (xstreq(head, "memories")) { + list_memories(dot); + free(head); + return false; + } + if (xstreq(head, "help")) { + out_str(" /model [name] show/switch model, or a `models` config\n" + " profile name (e.g. `/model local`)\n" + " /agent [name] list sub-agents, switch, or 'default'\n" + " /agents show the loaded .agents configuration\n" + " /tasks list repeat tasks\n" + " /memories list memories\n" + " /compact summarize the earlier context now\n" + " /undo roll back the last user turn\n" + " /clear clear the screen and the conversation\n" + " /save [path] save the session (default: " + "/tmp/llsh-session.json; resume with --resume )\n" + " /export [path] write the transcript to Markdown\n" + " /goal [criteria] run a goal-based loop\n" + " /tools list available tools\n" + " /exit quit\n" + "\nkeys:\n" + " Enter submit\n" + " Esc / Ctrl+C stop the running generation\n" + " Ctrl+D quit when the line is empty\n" + " Up / Down walk the multi-line buffer; at the top or\n" + " bottom, recall the previous / next input\n" + " Left/Right, Home, End, Backspace, Delete edit the line\n" + " Ctrl+Y yank the most recently deleted text\n" + " Ctrl+_ undo the last edit\n" + " \\+Enter newline (works in every terminal)\n"); + free(head); + return false; + } + out_fmt(RED "unknown command `/%.*s` (try /help)" RESET "\n", + (int)head_len, head); + free(head); + return false; +} + +/* ------------------------------------------------------------------ */ +/* REPL */ +/* ------------------------------------------------------------------ */ + +/* True when a line submitted in the interactive shell is the shell's own + * `exit` builtin (with or without an exit-status argument). Mirrors + * shell_line_is_exit in main.rs. */ +static bool shell_line_is_exit(const char *line) { + while (*line == ' ' || *line == '\t') + line++; + const char *p = line; + while (*p && *p != ' ' && *p != '\t') + p++; + size_t n = (size_t)(p - line); + return n == 4 && xstreq(line, "exit"); +} + +/* Commit a shell-mode visit to the conversation context so the model sees the + * commands the user ran and their output. The captured PTY text already + * contains each echoed command line (with the prompt) followed by its output, + * sanitized of terminal escapes, so it is recorded as-is (trimmed and capped + * at `max_chars`). One user message + a short assistant acknowledgement. + * Must be called before `shell_free`; empty visits record nothing. */ +static void shell_transcript_record(session *s, interactive_shell *sh, + unsigned long max_chars) { + if (!s || !sh) + return; + char *out = shell_captured(sh); + size_t len = strlen(out); + /* Trim trailing whitespace so the message does not end in blank lines. */ + while (len > 0 && (out[len - 1] == '\n' || out[len - 1] == '\r' || + out[len - 1] == ' ' || out[len - 1] == '\t')) + len--; + if (len == 0) { + free(out); + return; /* empty visit: nothing to record */ + } + bool truncated = (max_chars > 0 && len > max_chars); + if (truncated) + len = max_chars; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, out, len); + xbuf_push(&b, '\n'); + if (truncated) + xbuf_put(&b, "… (shell output truncated)\n"); + char *body = xbuf_steal(&b); + xbuf_free(&b); + free(out); + session_push_history(s, msg_user_text(body)); + session_push_history(s, msg_assistant_text( + "Shell visit noted; the commands and their output are in context. " + "Continue in chat or press Backtab to return to the shell.")); + session_set_last_context_tokens(s, 0); /* re-reported next turn */ + free(body); +} + +/* Make the process — and with it the file tools, `run_shell`, and the + * workspace `.agents` layer — follow the directory the shell ended in. + * Mirrors adopt_shell_cwd in main.rs. */ +static void adopt_shell_cwd(session *s, interactive_shell *sh) { + char *dir = shell_final_cwd(sh); + if (!dir) + return; + char *cur = NULL; + if (getcwd(cur, 0) == NULL) { + cur = xstrdup("."); + } + bool same = false; + if (cur) + same = xstreq(dir, cur); + free(cur); + if (same) { + free(dir); + return; + } + if (chdir(dir) != 0) { + out_fmt(DIM "warning: the shell ended in `%s`, but llsh could not " + "change to it" RESET "\n", + dir); + free(dir); + return; + } + free(dir); + char *err = NULL; + if (session_refresh_dot(s, NULL, NULL, &err) != 0) { + fprintf(stderr, "warning: could not refresh the agent layer after " + "changing directory: %s\n", + err ? err : "unknown error"); + free(err); + } +} + +/* Terminal resized: the shell-mode branch propagates the new size to the + * PTY; in chat mode llsh owns no screen state, so the terminal reflows the + * scrollback itself (see the g_winch branch in repl). */ +static volatile sig_atomic_t g_winch = 0; +static void on_winch(int sig) { + (void)sig; + g_winch = 1; +} + +/* Echo a codepoint as UTF-8, the way the terminal would show it. */ +static void echo_cp(unsigned int cp) { + char tmp[8]; + size_t i = 0; + if (cp < 0x80) + tmp[i++] = (char)cp; + else if (cp < 0x800) { + tmp[i++] = (char)(0xC0 | (cp >> 6)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + tmp[i++] = (char)(0xE0 | (cp >> 12)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } else { + tmp[i++] = (char)(0xF0 | (cp >> 18)); + tmp[i++] = (char)(0x80 | ((cp >> 12) & 0x3F)); + tmp[i++] = (char)(0x80 | ((cp >> 6) & 0x3F)); + tmp[i++] = (char)(0x80 | (cp & 0x3F)); + } + tmp[i] = '\0'; + out_str(tmp); +} + +/* Current terminal width in columns (from the controlling tty). The editor + * must lay the buffer out against this width, or a line that wraps will leave + * stale rows behind and the caret will land on the wrong row. */ +static size_t term_cols(void) { + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return (size_t)ws.ws_col; + return 80; +} + +/* Map the editor caret (a byte offset) to a physical (row, col) on a terminal + * of width `w`. Shared with the tests via input.cpp. */ +static void caret_phys(const char *text, size_t caret, size_t w, + size_t *out_row, size_t *out_col) { + input_wrap_caret(text, caret, w, out_row, out_col); +} + +/* Total physical rows of the buffer and the caret's physical (row, col). */ +static void editor_geom(const editor *ed, size_t w, size_t *total, + size_t *cr, size_t *cc) { + const char *t = ed->text ? ed->text : ""; + *total = input_wrap_total(t, w); + caret_phys(t, ed->caret, w, cr, cc); +} + +/* Display width of the character ending just before `caret` — the one a + * backspace at the caret would delete. Zero-width characters count as 1. */ +static size_t prev_char_width(const char *s, size_t caret) { + if (!s || caret == 0) + return 1; + size_t start = caret; + while (start > 0 && (((unsigned char)s[start - 1]) & 0xC0) == 0x80) + start--; + size_t w = input_display_len_n(s + start, caret - start); + return w ? w : 1; +} + +/* Move the visible cursor from where the previous draw or edit parked it + * (from_row, from_col) to the editor caret, without touching the printed + * text. Vertical first, then horizontal: the horizontal step lands the + * column regardless of how the terminal resolves the vertical one (e.g. out + * of a deferred-wrap cell). */ +static void caret_move_to(const editor *ed, size_t w, size_t from_row, + size_t from_col) { + size_t nr, nc; + caret_phys(ed->text ? ed->text : "", ed->caret, w, &nr, &nc); + if (nr < from_row) + esc_cursor_up((u32)(from_row - nr)); + else if (nr > from_row) + esc_cursor_down((u32)(nr - from_row)); + if (nc < from_col) + esc_cursor_left((u32)(from_col - nc)); + else if (nc > from_col) + esc_cursor_right((u32)(nc - from_col)); + /* esc_cursor_* write via fprintf without flushing; a line-buffered tty + * would hold the movement until the next out_str. Flush now so an arrow + * key responds immediately instead of on the next keypress. */ + fflush(stdout); +} + +/* Write the input block and park the visible caret at the editor caret. + * After printing the whole buffer the terminal cursor sits where the + * delayed-wrap model puts the final byte — the "end" position. Walk from there + * to the caret: up/down to its row (CUU/CUD preserve the column, which stays + * the end column), then left/right to the caret's column. */ +static void input_block_draw(const editor *ed) { + out_str(CHAT_PROMPT); + const char *text = ed->text; + size_t len = text ? strlen(text) : 0; + if (len > 0) + out_str(text); + size_t w = term_cols(); + const char *t = text ? text : ""; + size_t er, ec; + caret_phys(t, len, w, &er, &ec); + size_t cr, cc; + caret_phys(t, ed->caret, w, &cr, &cc); + if (ec == w - 1) { + /* The last byte landed in the row's final cell. Some terminals leave + * the cursor in a pending-wrap state (visually on the next row); a CR + * collapses it back to this row, and the right walk restores the + * column so the park below starts from a concrete cell. */ + out_str("\r"); + esc_cursor_right((u32)ec); + } + if (cr < er) { + for (size_t i = 0; i < er - cr; i++) + out_str(ESC_CURSOR_UP(1)); + } else if (cr > er) { + for (size_t i = 0; i < cr - er; i++) + out_str(ESC_CURSOR_DOWN(1)); + } + if (cc < ec) { + for (size_t i = 0; i < ec - cc; i++) + out_str(ESC_CURSOR_LEFT(1)); + } else if (cc > ec) { + for (size_t i = 0; i < cc - ec; i++) + out_str(ESC_CURSOR_RIGHT(1)); + } + fflush(stdout); +} + +/* Repaint the whole input block, parking the caret. The visible cursor sits + * on `old_caret_row` of the previously drawn block (`old_total` physical + * rows). The erase walk stays inside the old block's footprint, so it can + * never scroll the screen (cursor-down clamps at the last row); the repaint + * then scrolls naturally if the new block grows past the bottom row. */ +static void line_redraw(const editor *ed, size_t old_caret_row, + size_t old_caret_col, size_t old_total) { + if (old_total == 0) old_total = 1; + if (old_caret_row >= old_total) old_caret_row = old_total - 1; + (void)old_caret_col; + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + for (size_t i = 0; i < old_caret_row; i++) + out_str(ESC_CURSOR_PREVIOUS(1) ESC_CLEAR_LINE); + for (size_t i = 0; i + 1 < old_total; i++) + out_str(ESC_CURSOR_DOWN(1) ESC_CLEAR_LINE); + for (size_t i = 0; i + 1 < old_total; i++) + out_str(ESC_CURSOR_PREVIOUS(1)); + input_block_draw(ed); +} + +/* The session's static header, printed verbatim above the prompt. Drawn + * once at startup (after the provider-reported context window has been + * adopted), and reprinted inline on a model/agent switch. */ +static void banner_print(session *s, const dotagents *dot) { + char *model = session_model(s); + out_fmt("llsh — model: %s\n", model); + free(model); + unsigned long limit = session_context_limit(s); + if (limit > 0) { + char *ls = itoa_dup((long long)limit); + out_fmt("context window: %s\n", ls); + free(ls); + } else { + out_str("context window: unknown — automatic compaction is off; set " + "`context_limit` in `.agents/models.json` or pass " + "--context-limit to enable it\n"); + } + char *preset = session_active_preset(s); + if (preset) { + out_fmt("model preset: %s (from .agents models.json)\n", preset); + free(preset); + } + char *profile = session_profile_name(s); + if (profile) { + out_fmt("sub-agent: %s\n", profile); + free(profile); + } + for (size_t i = 0; i < dot->found_count; i++) + out_fmt("loaded %s: %s\n", layer_display(dot->found_layers[i]), + dot->found_paths[i]); + for (size_t i = 0; i < dot->warnings.len; i++) + fprintf(stderr, "warning: %s\n", dot->warnings.items[i]); + out_str("type /help for commands, /exit to quit\n"); +} + +/* Background context-window refresh (started on a /model switch). The + * fetch runs in a worker thread so the prompt stays responsive; the banner + * is reprinted at the call site once the worker has landed. */ +static pthread_mutex_t banner_mu = PTHREAD_MUTEX_INITIALIZER; +static pthread_t banner_worker; + +static void *banner_worker_run(void *arg) { + session *s = (session *)arg; + session_refresh_context_limit(s); + return NULL; +} + +/* Start the refresh worker (joining any previous one first). Returns 0 on + * success; on failure the limit is adopted synchronously instead. */ +static int banner_refresh_start(session *s) { + pthread_mutex_lock(&banner_mu); + pthread_t old = banner_worker; + if (old != 0) { + pthread_mutex_unlock(&banner_mu); + pthread_join(old, NULL); + pthread_mutex_lock(&banner_mu); + } + int rc = pthread_create(&banner_worker, NULL, banner_worker_run, s); + if (rc != 0) { + banner_worker = 0; + pthread_mutex_unlock(&banner_mu); + session_refresh_context_limit(s); + return -1; + } + pthread_mutex_unlock(&banner_mu); + return 0; +} + +/* Join the background refresh worker, if one is running (idempotent). + * Must be called before the session is freed, since the worker reads it. */ +static void banner_worker_join(void) { + pthread_mutex_lock(&banner_mu); + pthread_t w = banner_worker; + banner_worker = 0; + pthread_mutex_unlock(&banner_mu); + if (w != 0) + pthread_join(w, NULL); +} + +/* Clear the pending flag (used after an immediate reprint). */ +static int repl(session *s, const dotagents *dot) { + banner_print(s, dot); + + /* The reader takes ownership of the ctx (and its parser) and releases + * them in input_key_reader_free, so heap-allocate here. */ + key_parser *parser = (key_parser *) xmalloc(sizeof *parser); + key_parser_init(parser); + reader_ctx *ctx = (reader_ctx *) xmalloc(sizeof *ctx); + memset(ctx, 0, sizeof *ctx); + ctx->parser = parser; + input_key_reader *reader = input_key_reader_new(ctx); + + if (input_setup_terminal() != 0) { + input_key_reader_free(reader); + fprintf(stderr, "could not enter raw mode\n"); + return -1; + } + if (input_key_reader_start(reader) != 0) { + input_teardown_terminal(); + input_key_reader_free(reader); + fprintf(stderr, "could not start the input reader\n"); + return -1; + } + set_confirm_hook(interactive_confirm, reader); + struct sigaction sa; + memset(&sa, 0, sizeof sa); + sa.sa_handler = on_winch; + (void)sigaction(SIGWINCH, &sa, NULL); + + editor ed; + editor_init(&ed); + char *hist_path = input_default_history_path(); + input_history *hist = input_history_open(hist_path); + if (!hist) + hist = input_history_new(); + free(hist_path); + + bool running = true; + /* True when we're exiting with the cursor already parked at the start of a + * fresh line (a newline was the last thing emitted). The teardown must not + * add another, or the shell prompt lands a line too low. */ + bool clean_exit = false; + /* Interactive shell state (Backtab to enter/leave). NULL = chat mode. */ + interactive_shell *shell = NULL; + editor shell_shadow; + editor_init(&shell_shadow); + bool shell_exit_requested = false; + + /* Leave shell mode: adopt where the shell ended, tear it down, and return + * to the chat prompt (or quit if the shell's `exit` was submitted). */ + #define LEAVE_SHELL(record_visit) \ + do { \ + /* Record before freeing: the captured output lives in the \ + * shell, and its reader must be joined first. */ \ + if (record_visit) \ + shell_transcript_record(s, shell, \ + session_config(s)->max_output_chars); \ + adopt_shell_cwd(s, shell); \ + shell_free(shell); \ + shell = NULL; \ + editor_clear(&shell_shadow); \ + bool quit = shell_exit_requested; \ + shell_exit_requested = false; \ + /* The shell is gone: llsh owns the keys again. */ \ + out_str("\x1b[>1u"); \ + /* Erase the shell's prompt (drawn on our line): the next thing \ + * we print (or nothing, on quit) fully replaces it. */ \ + out_str("\r" ESC_CLEAR_LINE); \ + if (quit) { \ + running = false; \ + clean_exit = true; /* line is blank, cursor at col 0 */ \ + } \ + } while (0) + + while (running) { + if (shell) { + if (g_winch) { + g_winch = 0; + shell_update_winsize(shell); + } + input_key key; + const unsigned char *raw; + size_t raw_len; + if (input_key_reader_recv(reader, &key, &raw, &raw_len)) { + if (key.kind == KEY_BACKTAB) { + input_raw_free(raw); + LEAVE_SHELL(true); + continue; + } + shell_write(shell, raw, raw_len); + input_raw_free(raw); + char *submitted = shell_shadow_apply(&shell_shadow, key); + if (submitted) { + input_history_push(hist, submitted); + if (shell_line_is_exit(submitted)) + shell_exit_requested = true; + free(submitted); + } + continue; + } + if (shell_reap_if_dead(shell)) + LEAVE_SHELL(true); + sleep_ms(50); + continue; + } + + if (g_winch) { + g_winch = 0; + /* Chat mode: llsh owns no screen state — the terminal reflows the + * scrollback itself on resize, so there is nothing to do. */ + } + + /* Draw the current buffer and park the caret: a fresh prompt when + * empty, or a leftover buffer when returning from shell mode. The + * line is erased first — on a mode switch it still holds the + * shell's prompt, and at startup the parent shell's — so our prompt + * replaces it instead of printing behind it. */ + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + if (editor_is_empty(&ed)) + out_str(CHAT_PROMPT); + else + input_block_draw(&ed); + + /* Read keys until the buffer commits (submit, quit, or mode switch). + * Appends echo at the end of the buffer, backspaces erase in place, + * caret moves only move the cursor; mid-buffer edits repaint the + * whole block. */ + for (;;) { + input_key key; + bool got_key = input_key_reader_recv(reader, &key, NULL, NULL); + /* A resize reflows the screen and may park the cursor off the text; + * re-anchor the block to the caret before acting, or the next key + * lands on a stale line. */ + if (g_winch) { + g_winch = 0; + if (!editor_is_empty(&ed)) { + size_t w = term_cols(); + size_t gt, gcr, gcc; + editor_geom(&ed, w, >, &gcr, &gcc); + line_redraw(&ed, gcr, gcc, gt); + } + } + if (!got_key) { + sleep_ms(50); + continue; + } + /* The visible cursor sits where the last draw parked the caret. + * The old block's physical footprint (row count + caret row/col) + * is what the erase walk covers, so it must be captured before + * the key mutates the buffer. */ + size_t term_w = term_cols(); + size_t old_total, old_caret_row, old_caret_col; + editor_geom(&ed, term_w, &old_total, &old_caret_row, &old_caret_col); + + /* Up/Down: a single policy decides whether this press moves the + * caret or walks history. While composing, Up engages from the + * first line and Down only after a recall; once a recall is + * active the walk continues until any non-arrow key ends it. */ + if (key.kind == KEY_UP || key.kind == KEY_DOWN) { + char *recalled = input_history_walk( + hist, key.kind == KEY_UP ? -1 : +1, + editor_caret_line(&ed), ed.text); + if (recalled) { + editor_set(&ed, recalled); + free(recalled); + /* The recalled entry may be multi-line: the block + * grows/shrinks, so repaint the whole thing (the old + * block's footprint is what line_redraw erases). */ + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + continue; + } + } else { + input_history_reset(hist); + } + + size_t before = ed.text ? strlen(ed.text) : 0; + bool at_end = (ed.caret == before); + bool old_pending = false; + if (key.kind == KEY_BACKSPACE && at_end) + old_pending = + input_wrap_pending(ed.text ? ed.text : "", ed.caret, term_w); + input_action a; + editor_apply(&ed, key, &a); + bool changed = (ed.text ? strlen(ed.text) : 0) != before; + size_t new_caret_row = 0; + if (changed) { + size_t ncr, ncc; + caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &ncr, &ncc); + new_caret_row = ncr; + } + bool moved = (key.kind == KEY_LEFT || key.kind == KEY_RIGHT || + key.kind == KEY_HOME || key.kind == KEY_END || + key.kind == KEY_UP || key.kind == KEY_DOWN); + bool newline = (a.kind == ACT_NEWLINE); + + /* Render the key's effect shell-style: the text already on the + * terminal stays put; only what the key changed is emitted. + * - backspace at the end: "\b \b" erases the character in place + * (a wide one needs one more "\b " for its second cell); the + * blank left behind is past the caret, so no gap is visible. + * Use it only when the caret stays on the same physical row: + * a pending-wrap caret sits on the cell being erased, and an + * unwrap (deleting the last char of a row) would leave the + * visible cursor on the now-empty row below. + * - backspace deleting a newline at the end: the line below can + * only hold echo residue (the newline was the last byte), so + * the join is clear-that-line, up, across to the end of the + * line above. + * - append at the end: print the character — the terminal does + * the wrapping. + * - backslash+Enter at the end: the backslash became a newline — + * erase it and drop to the next line. + * - caret move: nothing was printed or erased, so just move the + * visible cursor to the caret. + * - everything else (mid-buffer insert, kills, yank, undo) + * repaints the whole block. */ + if (changed && key.kind == KEY_BACKSPACE && at_end && + old_caret_col != 0 && !old_pending && + new_caret_row == old_caret_row) { + out_str(prev_char_width(ed.text, ed.caret) >= 2 ? "\b \b " + : "\b \b"); + } else if (changed && key.kind == KEY_BACKSPACE && at_end && + old_caret_col == 0 && !old_pending) { + out_str(ESC_CLEAR_LINE); + esc_cursor_up(1); + size_t cr, cc; + caret_phys(ed.text ? ed.text : "", ed.caret, term_w, &cr, + &cc); + if (cc > 0) + esc_cursor_right((u32)cc); + } else if (changed && at_end) { + if (key.kind == KEY_CHAR) + echo_cp(key.cp); + else if (key.kind == KEY_TAB) + out_str("\t"); + else if (key.kind == KEY_NEWLINE) + out_str("\n"); + else if (key.kind == KEY_CTRL_Y) + out_str(ed.killed ? ed.killed : ""); + else + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + } else if (newline && at_end) { + /* If the backslash wrapped onto its own visual row, erasing it + * leaves that row empty and a plain LF would drop past it, + * leaving a blank line. Collapse back to the end of the row + * above first, then drop down. */ + size_t pr = 0, pc = 0; + bool wrap_split = false; + if (old_caret_row > 0 && old_caret_col == 1 && ed.caret >= 2 && + ed.text[ed.caret - 2] != '\n') { + caret_phys(ed.text, ed.caret - 2, term_w, &pr, &pc); + if (pc == term_w - 1) + wrap_split = true; + } + if (wrap_split) { + out_str("\b "); + out_str(ESC_CURSOR_PREVIOUS(1)); + if (pc > 0) + esc_cursor_right((u32)pc); + out_str("\n"); + } else { + out_str("\b \n"); + } + } else if (moved) { + caret_move_to(&ed, term_w, old_caret_row, old_caret_col); + } else if (changed || newline) { + line_redraw(&ed, old_caret_row, old_caret_col, old_total); + } + + bool leave = false; + switch (a.kind) { + case ACT_NONE: + case ACT_STOP: + case ACT_NEWLINE: /* already repainted above; stay in the loop */ + input_action_free(&a); + break; + case ACT_CLEAR_SCREEN: + input_action_free(&a); + out_str(ESC_CLEAR_SCREEN ESC_CURSOR_POSITION(1, 1)); + input_block_draw(&ed); + break; + case ACT_QUIT: + input_action_free(&a); + running = false; + leave = true; + break; + case ACT_TOGGLE_MODE: { + input_action_free(&a); + /* Erase the whole chat prompt line so the shell's own prompt + * prints on a clean line instead of after the leftover bullet. + * (The Rust reference does the same: erase the frame and let + * the shell draw its own prompt — no bullet reprint here.) */ + out_str(ESC_CURSOR_LEFT(1) ESC_CURSOR_COLUMN_HOME ESC_CLEAR_LINE); + interactive_shell *sh = shell_spawn(); + if (!sh) { + out_fmt(RED "error: failed to start interactive shell" RESET + "\n"); + leave = true; + break; + } + /* The shell owns the keys from here: give it ordinary xterm + * sequences, not kitty protocol. */ + out_str("\x1b[>0u"); + editor_clear(&shell_shadow); + /* Bound the in-memory capture at the tool output limit so a + * pathological command can't grow it without limit; the + * recorded transcript is trimmed to the same limit. */ + sh->capture_cap = session_config(s)->max_output_chars; + shell_exit_requested = false; + shell = sh; + shell_update_winsize(shell); + leave = true; + break; + } + case ACT_SUBMIT: { + input_action_free(&a); + char *text = editor_text(&ed); + char *t = text; + size_t tn = strlen(t); + while (tn && (t[tn - 1] == ' ' || t[tn - 1] == '\n' || + t[tn - 1] == '\t' || t[tn - 1] == '\r')) + t[--tn] = '\0'; + while (*t == ' ' || *t == '\n' || *t == '\t' || *t == '\r') + t++; + if (tn != 0) { + input_history_push(hist, t); + /* Submit's newline must fire from the very end of the + * input: when the caret sits mid-line, on a wrapped row, + * or on a middle line, the visible cursor is parked there + * and the newline would land mid-block — the remaining + * text (and then the response) shifts up over it. Walk the + * cursor to the block's bottom-right first: down to the + * last physical row (CUD preserves the column), then to + * that row's end column. Enter itself never moves the + * editor caret, so `ed.caret` still describes where the + * visible cursor sits. */ + { + size_t w = term_cols(); + size_t len = strlen(ed.text); + size_t er, ec; + caret_phys(ed.text, len, w, &er, &ec); + size_t cr, cc; + caret_phys(ed.text, ed.caret, w, &cr, &cc); + if (cr < er) { + for (size_t i = 0; i < er - cr; i++) + out_str(ESC_CURSOR_DOWN(1)); + } else if (cr > er) { + for (size_t i = 0; i < cr - er; i++) + out_str(ESC_CURSOR_PREVIOUS(1)); + } + if (cc < ec) { + for (size_t i = 0; i < ec - cc; i++) + out_str(ESC_CURSOR_RIGHT(1)); + } else if (cc > ec) { + for (size_t i = 0; i < cc - ec; i++) + out_str(ESC_CURSOR_LEFT(1)); + } + } + out_str("\n"); + editor_clear(&ed); + if (t[0] == '/') { + if (handle_command(s, dot, t + 1, reader)) { + running = false; + clean_exit = true; /* Enter-`\n` already emitted */ + } else { + /* Every command's output ends in `\n`, and the + * next prompt draw erases the line before + * printing (`/clear` repositions to the + * top-left). */ + } + } else { + /* The pacer terminates the turn's line with "\n"; + * the next prompt draw erases before printing. */ + run_turn_interactive(s, t, reader); + } + leave = true; + } + free(text); + break; + } + } + if (leave) + break; + } + } + + /* Join any in-flight refresh (a /model switch) before the session is + * freed — the worker reads it. */ + banner_worker_join(); + if (shell) { + adopt_shell_cwd(s, shell); + shell_free(shell); + shell = NULL; + } + editor_free(&shell_shadow); + input_key_reader_stop(reader); + input_key_reader_free(reader); /* frees ctx and parser too */ + editor_free(&ed); + input_history_free(hist); + input_teardown_terminal(); + if (!clean_exit) + out_str("\n"); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* One-shot */ +/* ------------------------------------------------------------------ */ + +static int one_shot(session *s, const char *prompt) { + std::atomic stop(false); + char *err = NULL; + int result = 0; + int rc = run_turn_paced(s, prompt, &stop, &err, &result); + if (rc < 0) { + fprintf(stderr, "%serror: %s%s\n", RED, err ? err : "turn failed", + RESET); + free(err); + return 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* main */ +/* ------------------------------------------------------------------ */ + +/* Worker for the parallel startup fetch: adopt the provider-reported + * context window (blocking, up to the HTTP timeout). */ +static void *startup_refresh(void *arg) { + session_refresh_context_limit((session *)arg); + return NULL; +} + +int main(int argc, char **argv) { + /* Writing to a pipe whose peer is gone (a dead MCP server, a shell that + * exited) must surface as EPIPE and be handled, not kill llsh with + * SIGPIPE. */ + signal(SIGPIPE, SIG_IGN); + char *err = NULL; + cli cli; + int prc = cli_parse(argc, argv, &cli, &err); + if (prc == CLI_HELP) { + cli_free(&cli); + return 0; + } + if (prc < 0) { + fprintf(stderr, "%s%s%s\n", RED, err ? err : "bad arguments", RESET); + free(err); + cli_free(&cli); + return 2; + } + + session *s = NULL; + llsh_model *main_model = NULL; + llsh_model *summarizer = NULL; + llsh_model *evaluator = NULL; + llsh_model *rubric = NULL; + mcp_registry mcp; + bool have_mcp = false; + int ret = 0; + + dotagents dot; + if (cli.resume && cli.resume[0]) { + session_snapshot *snap = session_load(cli.resume, &err); + if (!snap) { + fprintf(stderr, "%sresuming session from %s: %s%s\n", RED, + cli.resume, err ? err : "failed", RESET); + free(err); + cli_free(&cli); + return 1; + } + char *cwd = xstrdup(snap->cwd); + unsigned long count = (unsigned long) snap->hist.msgs.size(); + char *model = xstrdup(snap->cfg.model ? snap->cfg.model : ""); + if (chdir(cwd) != 0) { + fprintf(stderr, "entering saved working directory `%s`: %s\n", + cwd, strerror(errno)); + free(cwd); + free(model); + session_snapshot_free(snap); + cli_free(&cli); + return 1; + } + free(cwd); + dotagents_load(&dot); + strlist mcp_warnings; + sl_init(&mcp_warnings); + if (dot.mcp_count && cli.allow_mcp) { + mcp_connect_all((const char *const *)dot.mcp_names, + (const char *const *)dot.mcp_configs, + dot.mcp_count, &mcp, &mcp_warnings); + have_mcp = true; + } else if (dot.mcp_count) { + fprintf(stderr, "warning: %zu MCP server(s) not started; pass " + "--allow-mcp to trust .agents/mcp.json\n", + dot.mcp_count); + } + for (size_t i = 0; i < mcp_warnings.len; i++) + fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]); + sl_free(&mcp_warnings); + + main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL, + PROV_ROLE_MAIN); + s = session_resume(snap, dot, main_model, &err); + if (!s) { + fprintf(stderr, "resume failed: %s\n", err ? err : "unknown"); + free(err); + llsh_model_free(main_model); + if (have_mcp) + mcp_registry_free(&mcp); + cli_free(&cli); + return 1; + } + provider_model_attach(main_model, s); + summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER); + evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR); + rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC); + session_with_summarizer(s, summarizer); + session_with_goal_evaluator(s, evaluator); + session_with_rubric(s, rubric); + fprintf(stdout, "resumed session: %s — %lu message(s) in context\n", + model, count); + free(model); + } else { + config cfg; + if (config_from_cli(&cli, &cfg, &err) != 0) { + fprintf(stderr, "%sinvalid configuration: %s%s\n", RED, + err ? err : "unknown", RESET); + free(err); + cli_free(&cli); + return 2; + } + dotagents_load(&dot); + bool preset_applied = false; + if (dot.has_models && dot.models.default_name && + dot.models.default_name[0]) { + char *preset = config_apply_models_preset(&cfg, &dot.models, &cli, + &err); + if (!preset) { + fprintf(stderr, + "%sapplying .agents models.json default preset: %s%s\n", + RED, err ? err : "failed", RESET); + free(err); + config_free(&cfg); + dotagents_free(&dot); + cli_free(&cli); + return 2; + } + free(preset); + preset_applied = true; + } + if (!preset_applied && config_require_key_or_base(&cfg, &err) != 0) { + fprintf(stderr, "%s%s%s\n", RED, err ? err : "missing API key", + RESET); + free(err); + config_free(&cfg); + dotagents_free(&dot); + cli_free(&cli); + return 2; + } + strlist mcp_warnings; + sl_init(&mcp_warnings); + if (dot.mcp_count && cli.allow_mcp) { + mcp_connect_all((const char *const *)dot.mcp_names, + (const char *const *)dot.mcp_configs, + dot.mcp_count, &mcp, &mcp_warnings); + have_mcp = true; + } else if (dot.mcp_count) { + fprintf(stderr, "warning: %zu MCP server(s) not started; pass " + "--allow-mcp to trust .agents/mcp.json\n", + dot.mcp_count); + } + for (size_t i = 0; i < mcp_warnings.len; i++) + fprintf(stderr, "warning: %s\n", mcp_warnings.items[i]); + sl_free(&mcp_warnings); + + main_model = provider_model_new(NULL, have_mcp ? &mcp : NULL, + PROV_ROLE_MAIN); + s = session_new_with_model(main_model, cfg, dot); + provider_model_attach(main_model, s); + summarizer = provider_model_new(s, NULL, PROV_ROLE_SUMMARIZER); + evaluator = provider_model_new(s, NULL, PROV_ROLE_EVALUATOR); + rubric = provider_model_new(s, NULL, PROV_ROLE_RUBRIC); + session_with_summarizer(s, summarizer); + session_with_goal_evaluator(s, evaluator); + session_with_rubric(s, rubric); + } + + /* Wire process-wide behavior from the resolved config. */ + const config *cfg = session_config(s); + verbose_set(cfg->verbose); + if (cli.debug) + session_set_debug_path(s, xstrdup("debug.json")); + char *mname = session_model(s); + verbose_log("startup", + (vlog_kv[]){ VLOG_STR("model", mname ? mname : "") }, 1); + free(mname); + tool_limits tl; + tool_limits_defaults(&tl); + tl.shell_timeout_secs = cfg->shell_timeout_secs; + tl.max_output_chars = cfg->max_output_chars; + tl.max_file_bytes = cfg->max_file_bytes; + const strlist *allow = cli.shell_allowlist.len + ? &cli.shell_allowlist + : &cfg->shell_allowlist; + const strlist *deny = cli.shell_denylist.len + ? &cli.shell_denylist + : &cfg->shell_denylist; + for (size_t i = 0; i < allow->len; i++) + sl_push_copy(&tl.shell_allowlist, allow->items[i]); + for (size_t i = 0; i < deny->len; i++) + sl_push_copy(&tl.shell_denylist, deny->items[i]); + tl.confirm_destructive = cli.has_confirm_destructive + ? cli.confirm_destructive + : cfg->confirm_destructive; + tl.verbose = cfg->verbose; + tool_limits_set(&tl); + + /* Adopt the provider-reported context window before the banner is + * printed, so the `context window:` line is correct the first time. In + * the REPL this blocks (up to the HTTP timeout); in one-shot mode it + * runs in a thread so it overlaps the connectivity probe below. */ + if (cli.prompt == NULL && isatty(STDIN_FILENO)) { + session_refresh_context_limit(s); + } else { + pthread_t refresh_th; + int refresh_rc = pthread_create(&refresh_th, NULL, startup_refresh, s); + if (refresh_rc != 0) + session_refresh_context_limit(s); + if (refresh_rc == 0) + pthread_join(refresh_th, NULL); + } + + /* Connectivity probe. */ + provider_probe probe = agent_probe_provider(session_config(s)); + if (!probe.ok) { + fprintf(stderr, "warning: provider check failed: %s\n" + " check the provider, model, base_url, and " + "api_key; the first prompt may fail.\n", + probe.detail ? probe.detail : ""); + } + provider_probe_free(&probe); + + /* One-shot vs REPL. */ + char *piped_prompt = NULL; + const char *one_shot_prompt = cli.prompt; + if (!one_shot_prompt && !isatty(STDIN_FILENO)) { + xbuf piped; + xbuf_init(&piped); + char buf[4096]; + size_t n; + while ((n = read(STDIN_FILENO, buf, sizeof buf)) > 0) + xbuf_putn(&piped, buf, n); + char *s2 = xbuf_steal(&piped); + xbuf_free(&piped); + size_t l = strlen(s2); + while (l && isspace((unsigned char)s2[l - 1])) + s2[--l] = '\0'; + if (l) { + piped_prompt = s2; + one_shot_prompt = s2; + } else { + free(s2); + } + } + + if (one_shot_prompt && one_shot_prompt[0]) { + ret = one_shot(s, one_shot_prompt); + } else { + ret = repl(s, session_dot(s)); + } + free(piped_prompt); + + /* Join any background context-window refresh worker (e.g. left over + * from a /model switch) before the session is freed — it reads it. */ + banner_worker_join(); + + /* --debug: one last dump so the file reflects /undo, /clear, and any + * post-turn bookkeeping, not just the state at the last turn boundary. */ + if (s && cli.debug) { + char *derr = NULL; + if (session_debug_dump(s, &derr) == 0) { + fprintf(stderr, "[debug] wrote debug.json (%zu message(s))\n", + session_message_count(s)); + } else { + fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed"); + } + free(derr); + } + + /* Cleanup: session_free owns the models it adopted. */ + if (s) + session_free(s); + else { + llsh_model_free(main_model); + llsh_model_free(summarizer); + llsh_model_free(evaluator); + llsh_model_free(rubric); + } + if (have_mcp) + mcp_registry_free(&mcp); + cli_free(&cli); + return ret; +} diff --git a/src/mcp.cpp b/src/mcp.cpp new file mode 100644 index 0000000..94615a3 --- /dev/null +++ b/src/mcp.cpp @@ -0,0 +1,1010 @@ +#include "mcp.h" + +#include "config.h" /* LLSH_VERSION */ + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MCP_MAX_MESSAGE_BYTES (4u * 1024u * 1024u) + +typedef struct { + xbuf *buf; + size_t cap; +} mcp_write_ctx; + +static size_t mcp_curl_write(char *data, size_t size, size_t nmemb, void *ud) { + mcp_write_ctx *ctx = (mcp_write_ctx *)ud; + if (size && nmemb > SIZE_MAX / size) + return 0; + size_t n = size * nmemb; + if (n > ctx->cap - ctx->buf->len) + return 0; + xbuf_putn(ctx->buf, data, n); + return n; +} + +/* ------------------------------------------------------------------ */ +/* config parsing */ +/* ------------------------------------------------------------------ */ + +static char *jstr(const cJSON *v, const char *key) +{ + const cJSON *x = cJSON_GetObjectItemCaseSensitive(v, key); + if (cJSON_IsString(x) && x->valuestring) + return xstrdup(x->valuestring); + return NULL; +} + +static void jargs(const cJSON *v, mcp_server_config *out) +{ + const cJSON *a = cJSON_GetObjectItemCaseSensitive(v, "args"); + if (!cJSON_IsArray(a)) + return; + size_t n = (size_t)cJSON_GetArraySize(a); + char **arr = (char* * ) xmalloc((n ? n : 1) * sizeof(*arr)); + size_t used = 0; + const cJSON *x = NULL; + cJSON_ArrayForEach(x, a) + if (cJSON_IsString(x) && x->valuestring) + arr[used++] = xstrdup(x->valuestring); + out->args = arr; + out->arg_count = used; +} + +static void jkv(const cJSON *v, const char *key, mcp_kv **out, size_t *n) +{ + const cJSON *o = cJSON_GetObjectItemCaseSensitive(v, key); + if (!cJSON_IsObject(o)) + return; + size_t nitems = (size_t)cJSON_GetArraySize(o); + mcp_kv *arr = (mcp_kv *) xmalloc((nitems ? nitems : 1) * sizeof(*arr)); + size_t used = 0; + const cJSON *child = o->child; + while (child) { + if (child->string && cJSON_IsString(child) && child->valuestring) { + arr[used].key = xstrdup(child->string); + arr[used].val = xstrdup(child->valuestring); + used++; + } + child = child->next; + } + *out = arr; + *n = used; +} + +int mcp_server_config_from_json(const char *json, mcp_server_config *out) +{ + memset(out, 0, sizeof(*out)); + cJSON *v = json ? cJSON_Parse(json) : NULL; + if (!v || !cJSON_IsObject(v)) { + if (v) + cJSON_Delete(v); + return 0; + } + out->command = jstr(v, "command"); + out->url = jstr(v, "url"); + if (!out->command && !out->url) { + free(out->command); + free(out->url); + cJSON_Delete(v); + return 0; + } + jargs(v, out); + jkv(v, "env", &out->env, &out->env_count); + jkv(v, "headers", &out->headers, &out->header_count); + cJSON_Delete(v); + return 1; +} + +void mcp_server_config_free(mcp_server_config *c) +{ + if (!c) + return; + free(c->command); + free(c->url); + for (size_t i = 0; i < c->arg_count; i++) + free(c->args[i]); + free(c->args); + for (size_t i = 0; i < c->env_count; i++) { + free(c->env[i].key); + free(c->env[i].val); + } + free(c->env); + for (size_t i = 0; i < c->header_count; i++) { + free(c->headers[i].key); + free(c->headers[i].val); + } + free(c->headers); + memset(c, 0, sizeof(*c)); +} + +/* ------------------------------------------------------------------ */ +/* client */ +/* ------------------------------------------------------------------ */ + +enum { T_STDIO, T_HTTP, T_MEMORY }; + +struct mcp_client { + int kind; + unsigned long long next_id; + /* stdio */ + pid_t pid; + int in_fd; + int out_fd; + char *rb; /* carry-over bytes between line reads (partial line) */ + size_t rb_len; + /* http */ + char *url; + mcp_kv *headers; + size_t header_count; + /* memory */ + mcp_memory *mem; +}; + +long mcp_timeout_secs(void) +{ + const char *e = getenv("LLSH_MCP_TIMEOUT"); + if (e && e[0]) { + errno = 0; + char *end = NULL; + long v = strtol(e, &end, 10); + if (errno == 0 && end != e && *end == '\0' && v > 0) { + if (v > 2147483L) + return 2147483L; /* poll timeout is milliseconds in an int */ + return v; + } + } + return MCP_TIMEOUT_SECS; +} + +void mcp_memory_init(mcp_memory *m) +{ + sl_init(&m->responses); + sl_init(&m->sent); +} + +mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err) +{ + if (err) + *err = NULL; + if (!cfg->command) { + if (err) + *err = xstrdup("stdio server has no `command`"); + return NULL; + } + int in_pipe[2] = {-1, -1}, out_pipe[2] = {-1, -1}; + if (pipe(in_pipe) != 0 || pipe(out_pipe) != 0) { + if (err) + *err = xstrdup("i/o: pipe failed"); + if (in_pipe[0] >= 0) close(in_pipe[0]); + if (in_pipe[1] >= 0) close(in_pipe[1]); + if (out_pipe[0] >= 0) close(out_pipe[0]); + if (out_pipe[1] >= 0) close(out_pipe[1]); + return NULL; + } + /* Heap-allocate argv (a VLA sized by config data can overflow the stack + * for a large `args` array). Allocated pre-fork so the child inherits it. */ + char **argv = (char* * ) xmalloc((cfg->arg_count + 2) * sizeof *argv); + argv[0] = cfg->command; + for (size_t i = 0; i < cfg->arg_count; i++) + argv[i + 1] = cfg->args[i]; + argv[cfg->arg_count + 1] = NULL; + pid_t pid = fork(); + if (pid < 0) { + free(argv); + if (err) + *err = xstrdup("i/o: fork failed"); + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); + return NULL; + } + if (pid == 0) { + dup2(in_pipe[0], STDIN_FILENO); + dup2(out_pipe[1], STDOUT_FILENO); + close(in_pipe[0]); + close(in_pipe[1]); + close(out_pipe[0]); + close(out_pipe[1]); + int dn = open("/dev/null", O_WRONLY); + if (dn >= 0) { + dup2(dn, STDERR_FILENO); + close(dn); + } + for (size_t i = 0; i < cfg->env_count; i++) + setenv(cfg->env[i].key, cfg->env[i].val, 1); + /* Own process group so freeing the client can kill the whole tree. */ + setpgid(0, 0); + execvp(cfg->command, argv); + _exit(127); + } + free(argv); + close(in_pipe[0]); + close(out_pipe[1]); + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_STDIO; + c->pid = pid; + c->in_fd = in_pipe[1]; + c->out_fd = out_pipe[0]; + return c; +} + +mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err) +{ + if (err) + *err = NULL; + if (!cfg->url) { + if (err) + *err = xstrdup("HTTP server has no `url`"); + return NULL; + } + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_HTTP; + c->url = xstrdup(cfg->url); + if (cfg->header_count) { + c->headers = (mcp_kv *) xmalloc(cfg->header_count * sizeof(*c->headers)); + memcpy(c->headers, cfg->headers, + cfg->header_count * sizeof(*c->headers)); + for (size_t i = 0; i < cfg->header_count; i++) { + c->headers[i].key = xstrdup(cfg->headers[i].key); + c->headers[i].val = xstrdup(cfg->headers[i].val); + } + c->header_count = cfg->header_count; + } + return c; +} + +/* Test-only: wrap already-open stdio fds (the client closes the two it is + * given on free; no child process is reaped). */ +mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err) +{ + if (err) + *err = NULL; + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_STDIO; + c->in_fd = in_fd; + c->out_fd = out_fd; + return c; +} + +mcp_client *mcp_connect_memory(mcp_memory *mem, char **err) +{ + if (err) + *err = NULL; + mcp_client *c = (mcp_client *) xmalloc(sizeof(*c)); + memset(c, 0, sizeof(*c)); + c->kind = T_MEMORY; + c->mem = mem; + return c; +} + +void mcp_client_free(mcp_client *c) +{ + if (!c) + return; + if (c->kind == T_STDIO) { + if (c->in_fd >= 0) + close(c->in_fd); + if (c->out_fd >= 0) + close(c->out_fd); + free(c->rb); + if (c->pid > 0) { + int st = 0; + pid_t r = waitpid(c->pid, &st, WNOHANG); + if (r == 0) { + /* Kill the whole process group so grandchildren don't + * survive. */ + kill(-c->pid, SIGTERM); + for (int i = 0; i < 50 && + (r = waitpid(c->pid, &st, WNOHANG)) == 0; + i++) + usleep(10000); + if (r == 0) { + kill(-c->pid, SIGKILL); + waitpid(c->pid, &st, 0); + } + } + } + } else if (c->kind == T_HTTP) { + free(c->url); + for (size_t i = 0; i < c->header_count; i++) { + free(c->headers[i].key); + free(c->headers[i].val); + } + free(c->headers); + } + /* T_MEMORY: the mcp_memory belongs to the caller; we only borrowed it. */ + free(c); +} + +/* ------------------------------------------------------------------ */ +/* transports */ +/* ------------------------------------------------------------------ */ + +static int write_all_fd(int fd, const char *data, size_t len) +{ + size_t off = 0; + while (off < len) { + ssize_t w = write(fd, data + off, len - off); + if (w < 0) { + if (errno == EINTR) + continue; + return -1; + } + off += (size_t)w; + } + return 0; +} + +/* One line (no trailing newline) or NULL on EOF/timeout; *err on failure. + * Reads in chunks and carries any partial line in c->rb across calls, so a + * burst of lines in one read() is handled without per-byte syscalls. */ +static char *read_line_fd(mcp_client *c, const char *method, char **err) +{ + long tmo = mcp_timeout_secs(); + char *out = NULL; + for (;;) { + for (size_t i = 0; i < c->rb_len; i++) { + if (c->rb[i] == '\n') { + out = xstrndup(c->rb, i); + size_t rest = c->rb_len - i - 1; + memmove(c->rb, c->rb + i + 1, rest); + c->rb_len = rest; + goto done; + } + } + struct pollfd p = { .fd = c->out_fd, .events = POLLIN }; + int pr = poll(&p, 1, (int)(tmo * 1000)); + if (pr < 0) { + if (errno == EINTR) + continue; + if (err) + *err = xstrdup("i/o: read failed"); + return NULL; + } + if (pr == 0) { + if (c->rb_len == 0) { + if (err) + *err = xstrfmt("request `%s` timed out after %lds", method, + tmo); + } else { + out = xstrndup(c->rb, c->rb_len); + c->rb_len = 0; + } + goto done; + } + char tmp[64 * 1024]; + ssize_t r = read(c->out_fd, tmp, sizeof tmp); + if (r < 0) { + if (errno == EINTR) + continue; + break; + } + if (r == 0) + break; + if ((size_t)r > MCP_MAX_MESSAGE_BYTES - c->rb_len) { + free(c->rb); + c->rb = NULL; + c->rb_len = 0; + if (err) + *err = xstrdup("MCP stdio response line exceeds 4 MiB limit"); + return NULL; + } + char *nb = (char *) xrealloc(c->rb, c->rb_len + (size_t)r); + memcpy(nb + c->rb_len, tmp, (size_t)r); + c->rb = nb; + c->rb_len += (size_t)r; + } + if (c->rb_len > 0) { + out = xstrndup(c->rb, c->rb_len); + c->rb_len = 0; + } + done: + if (out) { + size_t n = strlen(out); + if (n > 0 && out[n - 1] == '\r') + out[n - 1] = '\0'; + } + if (!out || out[0] == '\0') { + free(out); + /* Keep a more specific error already set (e.g. timeout). */ + if (err && !*err) + *err = xstrdup("server closed the connection before answering"); + return NULL; + } + return out; +} + +/* Test-only: one buffered line read with carry semantics (read_line_fd). */ +char *mcp_read_line(mcp_client *c, const char *method, char **err) +{ + return read_line_fd(c, method, err); +} + +static int rt_stdio(mcp_client *c, const char *line, unsigned long long id, + const char *method, cJSON **resp, char **err) +{ + if (write_all_fd(c->in_fd, line, strlen(line)) != 0 || + write_all_fd(c->in_fd, "\n", 1) != 0) { + if (err) + *err = xstrdup("i/o: write failed"); + return -1; + } + for (;;) { + char *l = read_line_fd(c, method, err); + if (!l) + return -1; + size_t tlen; + const char *t = xtrim_span(l, strlen(l), &tlen); + if (tlen == 0) { + free(l); + continue; + } + cJSON *v = cJSON_Parse(t); + free(l); + if (!v) { + if (err) + *err = xstrdup("json: invalid message from server"); + return -1; + } + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (cJSON_IsNumber(idv) && + (unsigned long long)idv->valuedouble == id && + (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) { + *resp = v; + return 0; + } + cJSON_Delete(v); /* notification or unrelated message: skip */ + } +} + +static int rt_http(mcp_client *c, const char *json, unsigned long long id, + cJSON **resp, char **err) +{ + CURL *e = curl_easy_init(); + if (!e) { + if (err) + *err = xstrdup("http: could not initialize curl"); + return -1; + } + xbuf body; + xbuf_init(&body); + struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json"); + for (size_t i = 0; i < c->header_count; i++) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val); + hl = curl_slist_append(hl, hb.data); + xbuf_free(&hb); + } + curl_easy_setopt(e, CURLOPT_URL, c->url); + curl_easy_setopt(e, CURLOPT_POST, 1L); + curl_easy_setopt(e, CURLOPT_POSTFIELDS, json); + curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs()); + curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(e, CURLOPT_CAINFO, ca); + curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl); + mcp_write_ctx write_ctx = {&body, MCP_MAX_MESSAGE_BYTES}; + curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write); + curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(e); + if (rc != CURLE_OK) { + char *e2 = xstrfmt("http: %s", curl_easy_strerror(rc)); + free(body.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (err) + *err = e2; + return -1; + } + long code = 0; + curl_easy_getinfo(e, CURLINFO_RESPONSE_CODE, &code); + if (code >= 400) { + char *e2 = xstrfmt("HTTP %ld", code); + free(body.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (err) + *err = e2; + return -1; + } + char *ct = NULL; + curl_easy_getinfo(e, CURLINFO_CONTENT_TYPE, &ct); + char *body_text = xbuf_steal(&body); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + + char *local_err = NULL; + char *json_text = mcp_extract_json(ct ? ct : "", body_text, &local_err); + free(body_text); + if (!json_text) { + if (err) + *err = local_err; + else + free(local_err); + return -1; + } + cJSON *v = cJSON_Parse(json_text); + free(json_text); + if (!v) { + if (err) + *err = xstrdup("json: invalid HTTP body"); + return -1; + } + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (!cJSON_IsNumber(idv) || (unsigned long long)idv->valuedouble != id) { + cJSON_Delete(v); + if (err) + *err = xstrdup("HTTP response id does not match the request"); + return -1; + } + *resp = v; + return 0; +} + +static int rt_memory(mcp_client *c, const char *line, unsigned long long id, + cJSON **resp, char **err) +{ + sl_push_copy(&c->mem->sent, line); + for (;;) { + char *queued = sl_pop_front(&c->mem->responses); + if (!queued) { + if (err) + *err = xstrdup("no queued response (test transport exhausted)"); + return -1; + } + cJSON *v = cJSON_Parse(queued); + free(queued); + if (!v) + continue; + cJSON *idv = cJSON_GetObjectItemCaseSensitive(v, "id"); + if (cJSON_IsNumber(idv) && (unsigned long long)idv->valuedouble == id && + (cJSON_HasObjectItem(v, "result") || cJSON_HasObjectItem(v, "error"))) { + *resp = v; + return 0; + } + cJSON_Delete(v); + } +} + +static int roundtrip(mcp_client *c, const char *line, unsigned long long id, + const char *method, cJSON **resp, char **err) +{ + switch (c->kind) { + case T_STDIO: + return rt_stdio(c, line, id, method, resp, err); + case T_HTTP: + return rt_http(c, line, id, resp, err); + default: + return rt_memory(c, line, id, resp, err); + } +} + +static int notify_rt(mcp_client *c, const char *line, char **err) +{ + switch (c->kind) { + case T_STDIO: { + int rc = write_all_fd(c->in_fd, line, strlen(line)); + if (rc == 0) + rc = write_all_fd(c->in_fd, "\n", 1); + if (rc != 0 && err) + *err = xstrdup("i/o: write failed"); + return rc; + } + case T_HTTP: { + CURL *e = curl_easy_init(); + if (!e) { + if (err) + *err = xstrdup("http: could not initialize curl"); + return -1; + } + struct curl_slist *hl = curl_slist_append(NULL, "Content-Type: application/json"); + for (size_t i = 0; i < c->header_count; i++) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "%s: %s", c->headers[i].key, c->headers[i].val); + hl = curl_slist_append(hl, hb.data); + xbuf_free(&hb); + } + curl_easy_setopt(e, CURLOPT_URL, c->url); + curl_easy_setopt(e, CURLOPT_POST, 1L); + curl_easy_setopt(e, CURLOPT_POSTFIELDS, line); + curl_easy_setopt(e, CURLOPT_TIMEOUT, mcp_timeout_secs()); + curl_easy_setopt(e, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(e, CURLOPT_CAINFO, ca); + curl_easy_setopt(e, CURLOPT_HTTPHEADER, hl); + xbuf sink; + xbuf_init(&sink); + mcp_write_ctx write_ctx = {&sink, MCP_MAX_MESSAGE_BYTES}; + curl_easy_setopt(e, CURLOPT_WRITEFUNCTION, mcp_curl_write); + curl_easy_setopt(e, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(e); + free(sink.data); + curl_slist_free_all(hl); + curl_easy_cleanup(e); + if (rc != CURLE_OK) { + if (err) + *err = xstrfmt("http: %s", curl_easy_strerror(rc)); + return -1; + } + return 0; + } + default: + sl_push_copy(&c->mem->sent, line); + return 0; + } +} + +/* ------------------------------------------------------------------ */ +/* JSON-RPC layer */ +/* ------------------------------------------------------------------ */ + +char *mcp_request(mcp_client *c, const char *method, const char *params_json, + char **err) +{ + if (err) + *err = NULL; + unsigned long long id = ++c->next_id; + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "jsonrpc", "2.0"); + cJSON_AddNumberToObject(req, "id", (double)id); + cJSON_AddStringToObject(req, "method", method); + cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject(); + if (!params) + params = cJSON_CreateObject(); + cJSON_AddItemToObject(req, "params", params); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + cJSON *resp = NULL; + int rc = roundtrip(c, line, id, method, &resp, err); + free(line); + if (rc != 0) + return NULL; + + cJSON *e = cJSON_GetObjectItemCaseSensitive(resp, "error"); + if (e && cJSON_IsObject(e)) { + cJSON *code = cJSON_GetObjectItemCaseSensitive(e, "code"); + char *code_text = code ? cJSON_PrintUnformatted(code) : xstrdup("null"); + cJSON *msg = cJSON_GetObjectItemCaseSensitive(e, "message"); + char *out = xstrfmt("server error %s: %s", code_text, + cJSON_IsString(msg) && msg->valuestring + ? msg->valuestring : "unknown error"); + free(code_text); + cJSON_Delete(resp); + if (err) + *err = out; + else + free(out); + return NULL; + } + cJSON *result = cJSON_GetObjectItemCaseSensitive(resp, "result"); + char *out = result ? cJSON_PrintUnformatted(result) : xstrdup("null"); + cJSON_Delete(resp); + return out; +} + +int mcp_notify(mcp_client *c, const char *method, const char *params_json, + char **err) +{ + if (err) + *err = NULL; + cJSON *req = cJSON_CreateObject(); + cJSON_AddStringToObject(req, "jsonrpc", "2.0"); + cJSON_AddStringToObject(req, "method", method); + cJSON *params = params_json ? cJSON_Parse(params_json) : cJSON_CreateObject(); + if (!params) + params = cJSON_CreateObject(); + cJSON_AddItemToObject(req, "params", params); + char *line = cJSON_PrintUnformatted(req); + cJSON_Delete(req); + int rc = notify_rt(c, line, err); + free(line); + return rc == 0; +} + +int mcp_initialize(mcp_client *c, char **err) +{ + char *params = xstrfmt( + "{\"protocolVersion\":\"%s\",\"capabilities\":{},\"clientInfo\":" + "{\"name\":\"llsh\",\"version\":\"%s\"}}", + MCP_PROTOCOL_VERSION, LLSH_VERSION); + char *res = mcp_request(c, "initialize", params, err); + free(params); + if (!res) + return 0; + free(res); + char *err2 = NULL; + int ok = mcp_notify(c, "notifications/initialized", "{}", &err2); + if (!ok) { + if (err && !*err) + *err = err2; + else + free(err2); + return 0; + } + return 1; +} + +int mcp_list_tools(mcp_client *c, mcp_tool **out, size_t *n, char **err) +{ + char *res = mcp_request(c, "tools/list", "{}", err); + if (!res) + return 0; + cJSON *root = cJSON_Parse(res); + free(res); + if (!root) { + if (err) + *err = xstrdup("json: invalid tools/list result"); + return 0; + } + cJSON *tools = cJSON_GetObjectItemCaseSensitive(root, "tools"); + size_t count = cJSON_IsArray(tools) ? (size_t)cJSON_GetArraySize(tools) : 0; + mcp_tool *arr = (mcp_tool *) xmalloc((count ? count : 1) * sizeof(*arr)); + size_t used = 0; + if (cJSON_IsArray(tools)) { + const cJSON *t; + cJSON_ArrayForEach(t, tools) { + cJSON *name = cJSON_GetObjectItemCaseSensitive(t, "name"); + if (!cJSON_IsString(name) || !name->valuestring) + continue; + cJSON *desc = cJSON_GetObjectItemCaseSensitive(t, "description"); + cJSON *schema = cJSON_GetObjectItemCaseSensitive(t, "inputSchema"); + mcp_tool *slot = &arr[used++]; + memset(slot, 0, sizeof *slot); + slot->name = xstrdup(name->valuestring); + slot->description = cJSON_IsString(desc) && desc->valuestring + ? xstrdup(desc->valuestring) : xstrdup(""); + slot->input_schema = + schema ? cJSON_PrintUnformatted(schema) + : xstrdup("{\"type\":\"object\"}"); + if (!slot->input_schema) + slot->input_schema = xstrdup("{\"type\":\"object\"}"); + slot->client = c; + } + } + cJSON_Delete(root); + *out = arr; + *n = used; + return 1; +} + +void mcp_tools_free(mcp_tool *tools, size_t n) +{ + if (!tools) + return; + for (size_t i = 0; i < n; i++) { + free(tools[i].name); + free(tools[i].qualified_name); + free(tools[i].description); + free(tools[i].input_schema); + } + free(tools); +} + +char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json, + char **err) +{ + cJSON *name_j = cJSON_CreateString(name ? name : ""); + char *name_s = cJSON_PrintUnformatted(name_j); + cJSON_Delete(name_j); + char *params = xstrfmt("{\"name\":%s,\"arguments\":%s}", + name_s ? name_s : "\"\"", + args_json ? args_json : "{}"); + free(name_s); + char *res = mcp_request(c, "tools/call", params, err); + free(params); + if (!res) + return NULL; + cJSON *root = cJSON_Parse(res); + free(res); + if (!root) { + if (err) + *err = xstrdup("json: invalid tools/call result"); + return NULL; + } + cJSON *iserr = cJSON_GetObjectItemCaseSensitive(root, "isError"); + bool err_flag = cJSON_IsTrue(iserr); + cJSON *content = cJSON_GetObjectItemCaseSensitive(root, "content"); + xbuf text; + xbuf_init(&text); + if (cJSON_IsArray(content)) { + const cJSON *item; + cJSON_ArrayForEach(item, content) { + cJSON *t = cJSON_GetObjectItemCaseSensitive(item, "text"); + if (cJSON_IsString(t) && t->valuestring) { + if (text.len) + xbuf_push(&text, '\n'); + xbuf_put(&text, t->valuestring); + } else { + cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type"); + cJSON *data = cJSON_GetObjectItemCaseSensitive(item, "data"); + if ((cJSON_IsString(type) && type->valuestring && + strcmp(type->valuestring, "image") == 0) || data != NULL) { + if (text.len) + xbuf_push(&text, '\n'); + xbuf_put(&text, "[binary content]"); + } + } + } + } + if (text.len == 0) + xbuf_put(&text, "(no content)"); + char *out = xbuf_steal(&text); + cJSON_Delete(root); + if (err_flag) { + char *e = xstrfmt("tool `%s` reported an error: %s", name, out); + free(out); + if (err) + *err = e; + return NULL; + } + return out; +} + +char *mcp_extract_json(const char *content_type, const char *body, char **err) +{ + if (err) + *err = NULL; + if (content_type && strstr(content_type, "event-stream")) { + const char *p = body ? body : ""; + const char *best = NULL; + size_t best_len = 0; + while (*p) { + const char *nl = strchr(p, '\n'); + size_t linelen = nl ? (size_t)(nl - p) : strlen(p); + size_t tlen; + const char *line = xtrim_span(p, linelen, &tlen); + if (tlen > 5 && strncmp(line, "data:", 5) == 0) { + size_t dlen; + const char *d = xtrim_span(line + 5, tlen - 5, &dlen); + if (dlen > 0) { + best = d; + best_len = dlen; + } + } + if (!nl) + break; + p = nl + 1; + } + if (!best) { + if (err) + *err = xstrdup("SSE body had no `data:` line"); + return NULL; + } + char *txt = xstrndup(best, best_len); + cJSON *v = cJSON_Parse(txt); + free(txt); + if (!v) { + if (err) + *err = xstrdup("json: invalid SSE data line"); + return NULL; + } + char *out = cJSON_PrintUnformatted(v); + cJSON_Delete(v); + return out; + } + cJSON *v = cJSON_Parse(body ? body : ""); + if (!v) { + if (err) + *err = xstrdup("json: invalid HTTP body"); + return NULL; + } + char *out = cJSON_PrintUnformatted(v); + cJSON_Delete(v); + return out; +} + +/* ------------------------------------------------------------------ */ +/* registry */ +/* ------------------------------------------------------------------ */ + +void mcp_connect_all(const char *const *names, const char *const *configs, + size_t n, mcp_registry *out, strlist *warnings) +{ + memset(out, 0, sizeof(*out)); + mcp_tool *tools = NULL; + size_t tool_count = 0; + mcp_client **clients = NULL; + size_t client_count = 0; + + for (size_t i = 0; i < n; i++) { + const char *name = names[i]; + const char *cfg_json = configs[i]; + mcp_server_config cfg; + if (!mcp_server_config_from_json(cfg_json, &cfg)) { + sl_push(warnings, + xstrfmt("mcp `%s`: no `command` or `url`; skipped", name)); + continue; + } + + mcp_client *c = NULL; + char *err = NULL; + if (cfg.command) + c = mcp_connect_stdio(&cfg, &err); + else + c = mcp_connect_http(&cfg, &err); + if (!c) { + sl_push(warnings, xstrfmt("mcp `%s`: failed to start", name)); + free(err); + mcp_server_config_free(&cfg); + continue; + } + if (!mcp_initialize(c, &err)) { + sl_push(warnings, + xstrfmt("mcp `%s`: handshake failed: %s", name, + err ? err : "unknown")); + free(err); + mcp_client_free(c); + mcp_server_config_free(&cfg); + continue; + } + mcp_tool *t = NULL; + size_t tn = 0; + char *err2 = NULL; + if (!mcp_list_tools(c, &t, &tn, &err2)) { + sl_push(warnings, xstrfmt("mcp `%s`: tools/list failed", name)); + free(err2); + mcp_client_free(c); + mcp_server_config_free(&cfg); + continue; + } + for (size_t j = 0; j < tn; j++) { + t[j].qualified_name = xstrfmt("mcp__%zu_%s__%s", strlen(name), + name, t[j].name); + } + tools = (mcp_tool *) xrealloc(tools, (tool_count + tn) * sizeof(*tools)); + memcpy(tools + tool_count, t, tn * sizeof(*t)); + tool_count += tn; + free(t); + clients = + (mcp_client **)xrealloc(clients, (client_count + 1) * sizeof(*clients)); + clients[client_count++] = c; + mcp_server_config_free(&cfg); + } + + out->tools = tools; + out->tool_count = tool_count; + out->clients = clients; + out->client_count = client_count; +} + +void mcp_registry_free(mcp_registry *r) +{ + if (!r) + return; + mcp_tools_free(r->tools, r->tool_count); + for (size_t i = 0; i < r->client_count; i++) + mcp_client_free(r->clients[i]); + free(r->clients); + memset(r, 0, sizeof(*r)); +} + +char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err) +{ + char *out = mcp_call_tool(t->client, t->name, args_json, err); + if (out) + return out; + char *e = err ? *err : NULL; + if (err) + *err = xstrfmt("MCP tool `%s` failed: %s", t->name, e ? e : "unknown"); + free(e); + return NULL; +} diff --git a/src/mcp.h b/src/mcp.h new file mode 100644 index 0000000..24a35a9 --- /dev/null +++ b/src/mcp.h @@ -0,0 +1,112 @@ +#ifndef LLSH_MCP_H +#define LLSH_MCP_H + +#include "util.h" + +/* Mirrors mcp.rs: a minimal Model Context Protocol client. + * mcpServers entries are either stdio (command/args/env) or HTTP (url/headers). + * Transport framing is JSON-RPC 2.0: one message per line over stdio, a single + * request/response (plain JSON or SSE) over HTTP. A wedged server must not hang + * the agent: every call is bounded by MCP_TIMEOUT_SECS. */ + +#define MCP_TIMEOUT_SECS 120L +#define MCP_PROTOCOL_VERSION "2024-11-05" + +/* Effective per-call timeout. MCP_TIMEOUT_SECS unless the LLSH_MCP_TIMEOUT + * environment variable sets a positive whole number of seconds. */ +long mcp_timeout_secs(void); + +typedef struct { + char *key; + char *val; +} mcp_kv; + +/* One mcpServers entry. All fields owned (NULL/0 when absent). */ +typedef struct { + char *command; + char **args; + size_t arg_count; + mcp_kv *env; + size_t env_count; + char *url; + mcp_kv *headers; + size_t header_count; +} mcp_server_config; + +typedef struct mcp_client mcp_client; + +/* A tool advertised by a server's tools/list. client is a back-reference. */ +typedef struct { + char *name; /* remote name */ + char *qualified_name; /* exposed as mcp_____ */ + char *description; + char *input_schema; /* JSON text */ + mcp_client *client; +} mcp_tool; + +/* Everything connected: the flattened tool list plus the owning clients. */ +typedef struct { + mcp_tool *tools; + size_t tool_count; + mcp_client **clients; + size_t client_count; +} mcp_registry; + +/* Test-only loopback transport: queued responses + recorded outgoing lines. + * Call mcp_memory_init before use; the caller keeps ownership, the client + * borrows it for the lifetime of the connection. */ +typedef struct { + strlist responses; + strlist sent; +} mcp_memory; + +void mcp_memory_init(mcp_memory *m); + +/* Parse a raw mcpServers value (JSON text). Returns 1 and fills *out, or 0 + * (no error) when it declares neither a command nor a url. */ +int mcp_server_config_from_json(const char *json, mcp_server_config *out); +void mcp_server_config_free(mcp_server_config *c); + +/* Spawn/attach a server. Returns NULL with *err on failure. */ +mcp_client *mcp_connect_stdio(const mcp_server_config *cfg, char **err); +mcp_client *mcp_connect_http(const mcp_server_config *cfg, char **err); +/* Test-only: wrap already-open stdio fds (closed on free, no child reaped). */ +mcp_client *mcp_connect_fds(int in_fd, int out_fd, char **err); +/* Test-only: one buffered line read with carry semantics. */ +char *mcp_read_line(mcp_client *c, const char *method, char **err); +mcp_client *mcp_connect_memory(mcp_memory *mem, char **err); +void mcp_client_free(mcp_client *c); + +/* Handshake: initialize request + notifications/initialized notification. */ +int mcp_initialize(mcp_client *c, char **err); + +/* Discover the server's tools. Caller frees with mcp_tools_free (does not + * free the clients). */ +int mcp_list_tools(mcp_client *c, mcp_tool **tools, size_t *n, char **err); +void mcp_tools_free(mcp_tool *tools, size_t n); + +/* Invoke a tool; returns its text content or NULL with *err set. */ +char *mcp_call_tool(mcp_client *c, const char *name, const char *args_json, + char **err); + +/* Low-level JSON-RPC: returns the `result` value as JSON text (owned), or + * NULL with *err ("server error {code}: {message}" on JSON-RPC errors). */ +char *mcp_request(mcp_client *c, const char *method, const char *params_json, + char **err); +int mcp_notify(mcp_client *c, const char *method, const char *params_json, + char **err); + +/* Extract the JSON-RPC response from an HTTP body (plain or SSE). */ +char *mcp_extract_json(const char *content_type, const char *body, char **err); + +/* Connect, handshake, and discover every server, in order. Unreachable + * servers become warnings (`mcp \`name\`: reason`) and are skipped. */ +void mcp_connect_all(const char *const *names, const char *const *configs, + size_t n, mcp_registry *out, strlist *warnings); +void mcp_registry_free(mcp_registry *r); + +/* Dispatch through the owning client, wrapping failures as + * `MCP tool \`name\` failed: ...`. */ +char *mcp_tool_invoke(const mcp_tool *t, const char *args_json, char **err); + +#endif diff --git a/src/message.cpp b/src/message.cpp new file mode 100644 index 0000000..18e9c3b --- /dev/null +++ b/src/message.cpp @@ -0,0 +1 @@ +#include "message.h" diff --git a/src/message.h b/src/message.h new file mode 100644 index 0000000..6ff53f6 --- /dev/null +++ b/src/message.h @@ -0,0 +1,367 @@ +#ifndef LLSH_MESSAGE_H +#define LLSH_MESSAGE_H + +#include + +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Message model (mirrors the rig message types llsh uses) */ +/* */ +/* message = system_message { text } */ +/* | user_message { parts: [user_part] } */ +/* | assistant_message { parts: [assistant_part] } */ +/* user_part = user_text_part { text } */ +/* | user_tool_result_part { call, name, */ +/* content: [trc] } */ +/* trc = trc_text { text } | trc_json { value } | trc_image */ +/* assistant_part = asst_text_part { text } */ +/* | asst_tool_call_part { name, args } */ +/* | asst_reasoning_part { text } */ +/* | asst_image_part */ +/* */ +/* JSON shape (recursive, nlohmann ADL below): each type serializes */ +/* to exactly this wire format and the `role`/`type` discriminators */ +/* keep old session files loadable. */ +/* ------------------------------------------------------------------ */ + +/* -- tool result content -- */ + +struct trc_text { + std::string text; +}; +struct trc_json { + std::string value; +}; +struct trc_image { +}; +using tool_result_content = std::variant; + +/* -- user parts -- */ + +struct user_text_part { + std::string text; +}; +struct user_tool_result_part { + std::string call; /* tool-call id */ + std::string name; /* tool name */ + std::vector content; +}; +using user_part = std::variant; + +/* -- assistant parts -- */ + +struct asst_text_part { + std::string text; +}; +struct asst_tool_call_part { + std::string name; + std::string args; /* stringified arguments */ +}; +struct asst_reasoning_part { + std::string text; +}; +struct asst_image_part { +}; +using assistant_part = + std::variant; + +/* -- messages -- */ + +struct system_message { + std::string text; +}; +struct user_message { + std::vector parts; +}; +struct assistant_message { + std::vector parts; +}; + +struct message { + std::variant body; + + bool is_system() const { + return std::holds_alternative(body); + } + bool is_user() const { + return std::holds_alternative(body); + } + bool is_assistant() const { + return std::holds_alternative(body); + } + + system_message &system() { return std::get(body); } + const system_message &system() const { + return std::get(body); + } + user_message &user() { return std::get(body); } + const user_message &user() const { + return std::get(body); + } + assistant_message &assistant() { + return std::get(body); + } + const assistant_message &assistant() const { + return std::get(body); + } +}; + +/* Constructors (fresh message, content copied). */ +inline message msg_system(const char *text) { + system_message m; + m.text = text ? text : ""; + return message{std::move(m)}; +} +inline message msg_user_text(const char *text) { + user_message m; + user_text_part p; + p.text = text ? text : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_user_tool_result(const char *call, const char *name, + const char *text) { + user_message m; + user_tool_result_part p; + p.call = call ? call : ""; + p.name = name ? name : ""; + trc_text t; + t.text = text ? text : ""; + p.content.push_back(std::move(t)); + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_assistant_text(const char *text) { + assistant_message m; + asst_text_part p; + p.text = text ? text : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} +inline message msg_assistant_tool_call(const char *name, const char *args) { + assistant_message m; + asst_tool_call_part p; + p.name = name ? name : ""; + p.args = args ? args : ""; + m.parts.push_back(std::move(p)); + return message{std::move(m)}; +} + +/* Append parts to an existing message. */ +inline message &msg_push_user_text(message &m, const char *text) { + if (!m.is_user()) + m.body = user_message{}; + user_text_part p; + p.text = text ? text : ""; + m.user().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_user_tool_result(message &m, const char *call, + const char *name, const char *text) { + if (!m.is_user()) + m.body = user_message{}; + user_tool_result_part p; + p.call = call ? call : ""; + p.name = name ? name : ""; + trc_text t; + t.text = text ? text : ""; + p.content.push_back(std::move(t)); + m.user().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_text(message &m, const char *text) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_text_part p; + p.text = text ? text : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_tool_call(message &m, const char *name, + const char *args) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_tool_call_part p; + p.name = name ? name : ""; + p.args = args ? args : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} +inline message &msg_push_assistant_reasoning(message &m, const char *text) { + if (!m.is_assistant()) + m.body = assistant_message{}; + asst_reasoning_part p; + p.text = text ? text : ""; + m.assistant().parts.push_back(std::move(p)); + return m; +} + +/* ------------------------------------------------------------------ */ +/* History (growable message list) */ +/* ------------------------------------------------------------------ */ + +struct history { + std::vector msgs; +}; + +/* Destroy the stored messages and the vector buffer; the `history` object + * (wherever it lives) must not be used afterwards. */ +inline void history_free(history *h) { h->msgs.~vector(); } +inline void history_push(history *h, message m) { + h->msgs.push_back(std::move(m)); +} +/* Remove and return element i. */ +inline message history_take(history *h, size_t i) { + message out = std::move(h->msgs[i]); + h->msgs.erase(h->msgs.begin() + static_cast(i)); + return out; +} +inline size_t history_len(const history *h) { return h->msgs.size(); } +inline const message *history_at(const history *h, size_t i) { + return &h->msgs[i]; +} + +/* ------------------------------------------------------------------ */ +/* Recursive JSON (nlohmann ADL: to_json / from_json) */ +/* ------------------------------------------------------------------ */ + +/* Tolerant string read: missing / null / non-string values become "". */ +inline std::string jstring(const nlohmann::json &j, const char *key) { + if (j.contains(key) && j.at(key).is_string()) + return j.at(key).get(); + return std::string(); +} + +inline void to_json(nlohmann::json &j, const tool_result_content &v) { + if (const auto *t = std::get_if(&v)) + j = nlohmann::json{{"text", t->text}}; + else if (const auto *s = std::get_if(&v)) + j = nlohmann::json{{"json", s->value}}; + else + j = nlohmann::json{{"image", "1"}}; +} +inline void from_json(const nlohmann::json &j, tool_result_content &v) { + if (j.contains("text")) { + trc_text t; + t.text = jstring(j, "text"); + v = std::move(t); + } else if (j.contains("json")) { + trc_json s; + s.value = jstring(j, "json"); + v = std::move(s); + } else { + v = trc_image{}; + } +} + +inline void to_json(nlohmann::json &j, const user_part &p) { + if (const auto *t = std::get_if(&p)) + j = nlohmann::json{{"type", "text"}, {"text", t->text}}; + else { + const auto &r = std::get(p); + nlohmann::json arr = nlohmann::json::array(); + for (const auto &c : r.content) + arr.push_back(c); + j = nlohmann::json{{"type", "tool_result"}, + {"call", r.call}, + {"name", r.name}, + {"content", std::move(arr)}}; + } +} +inline void from_json(const nlohmann::json &j, user_part &p) { + const std::string type = jstring(j, "type"); + if (type == "text") { + user_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } else if (type == "tool_result") { + user_tool_result_part r; + r.call = jstring(j, "call"); + r.name = jstring(j, "name"); + if (j.contains("content") && j.at("content").is_array()) + for (const auto &c : j.at("content")) + r.content.push_back(c.get()); + p = std::move(r); + } else { + user_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } +} + +inline void to_json(nlohmann::json &j, const assistant_part &p) { + if (const auto *t = std::get_if(&p)) + j = nlohmann::json{{"type", "text"}, {"text", t->text}}; + else if (const auto *c = std::get_if(&p)) + j = nlohmann::json{{"type", "tool_call"}, + {"name", c->name}, + {"args", c->args}}; + else if (const auto *r = std::get_if(&p)) + j = nlohmann::json{{"type", "reasoning"}, {"text", r->text}}; + else + j = nlohmann::json::object(); +} +inline void from_json(const nlohmann::json &j, assistant_part &p) { + const std::string type = jstring(j, "type"); + if (type == "text") { + asst_text_part t; + t.text = jstring(j, "text"); + p = std::move(t); + } else if (type == "tool_call") { + asst_tool_call_part c; + c.name = jstring(j, "name"); + c.args = jstring(j, "args"); + p = std::move(c); + } else if (type == "reasoning") { + asst_reasoning_part r; + r.text = jstring(j, "text"); + p = std::move(r); + } else { + p = asst_image_part{}; + } +} + +inline void to_json(nlohmann::json &j, const message &m) { + if (m.is_system()) + j = nlohmann::json{{"role", "system"}, {"text", m.system().text}}; + else if (m.is_user()) { + nlohmann::json parts = nlohmann::json::array(); + for (const auto &p : m.user().parts) + parts.push_back(p); + j = nlohmann::json{{"role", "user"}, {"parts", std::move(parts)}}; + } else { + nlohmann::json parts = nlohmann::json::array(); + for (const auto &p : m.assistant().parts) + parts.push_back(p); + j = + nlohmann::json{{"role", "assistant"}, {"parts", std::move(parts)}}; + } +} +inline void from_json(const nlohmann::json &j, message &m) { + const std::string role = jstring(j, "role"); + if (role == "system") { + system_message s; + s.text = jstring(j, "text"); + m.body = std::move(s); + } else if (role == "assistant") { + assistant_message a; + if (j.contains("parts") && j.at("parts").is_array()) + for (const auto &p : j.at("parts")) + if (p.is_object()) + a.parts.push_back(p.get()); + m.body = std::move(a); + } else { + user_message u; + if (j.contains("parts") && j.at("parts").is_array()) + for (const auto &p : j.at("parts")) + if (p.is_object()) + u.parts.push_back(p.get()); + m.body = std::move(u); + } +} + +#endif diff --git a/src/provider.cpp b/src/provider.cpp new file mode 100644 index 0000000..8ac95db --- /dev/null +++ b/src/provider.cpp @@ -0,0 +1,941 @@ +/* provider.c — the session-bound provider layer for llshc. + * + * Implements the `llsh_model` vtable on top of llm.h's protocol layer: + * wires the session (config, preamble, allowed tools, .agents snapshot), + * the built-in + MCP tool registry, and the session emit callback into the + * one-shot `complete` and the streaming agent `stream`. */ +#include "provider.h" + +#include +#include +#include +#include +#include + +#include "compact.h" +#include "config.h" +#include "dotagents.h" +#include "goal.h" +#include "message.h" +#include "limits.h" +#include +#include "tools.h" +#include "util.h" + +#define PCAST(m) ((provider_model *)(m)) +#define jstr(o, k) ({ const cJSON *_v = (o) ? cJSON_GetObjectItem((o), (k)) : NULL; \ + (cJSON_IsString(_v) && _v->valuestring) ? _v->valuestring : ""; }) + +typedef struct provider_model { + llsh_model base; /* must be first */ + session *sess; /* non-owning back-pointer */ + mcp_registry *mcp; + provider_role role; + /* Cached `"tools":[...]` request fragment. Valid while (provider, + * allowed list, mcp tools) is unchanged; invalidated on key mismatch. */ + char *tools_json; + unsigned long tools_key; + CURL *curl; /* persistent easy handle, created on first post */ +} provider_model; + +/* ------------------------------------------------------------------ */ +/* Session -> llm.h adapters */ +/* ------------------------------------------------------------------ */ + +static llm_conn conn_of(const session *s) { + const config *cfg = session_active_config((session *)s); + llm_conn c; + switch (cfg->provider) { + case PROV_ANTHROPIC: + c.provider = LLM_ANTHROPIC; + break; + case PROV_OLLAMA: + c.provider = LLM_OLLAMA; + break; + default: + c.provider = LLM_OPENAI; + break; + } + c.model = cfg->model; + c.base_url = cfg->base_url; + c.api_key = cfg->api_key; + c.ca_bundle = llsh_ca_bundle(); + return c; +} + +/* Join the tool-result content parts of a user_tool_result_part. Caller + * frees. */ +static char *trc_join(const user_tool_result_part &r) { + xbuf b; + xbuf_init(&b); + bool first = true; + for (const auto &c : r.content) { + if (!first) + xbuf_push(&b, '\n'); + if (const auto *t = std::get_if(&c)) + xbuf_put(&b, t->text.c_str()); + else if (const auto *s = std::get_if(&c)) + xbuf_put(&b, s->value.c_str()); + first = false; + } + char *s = xbuf_steal(&b); + xbuf_free(&b); + return s; +} + +/* App messages -> borrowed llm_message array (tool-result parts joined). */ +typedef struct { + llm_message *msgs; + size_t n; + char **joined; /* owned joined trc strings */ + size_t joined_n; +} app_llm_msgs; + +static app_llm_msgs app_to_llm(const message *const *msgs, size_t n) { + app_llm_msgs a; + memset(&a, 0, sizeof a); + a.msgs = (llm_message *)xmalloc(n ? n * sizeof *a.msgs : 1); + a.n = n; + for (size_t i = 0; i < n; i++) { + const message *m = msgs[i]; + llm_message *o = &a.msgs[i]; + size_t cnt = 0; + if (m->is_system()) { + o->role = LLM_ROLE_SYSTEM; + cnt = 1; + } else if (m->is_user()) { + o->role = LLM_ROLE_USER; + cnt = m->user().parts.size(); + } else { + o->role = LLM_ROLE_ASSISTANT; + cnt = m->assistant().parts.size(); + } + if (cnt == 0) + continue; + o->parts = (llm_part *)xmalloc(cnt * sizeof *o->parts); + size_t k = 0; + if (m->is_system()) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = m->system().text.c_str(); + k++; + } else if (m->is_user()) { + for (size_t j = 0; j < m->user().parts.size(); j++) { + const auto &p = m->user().parts[j]; + if (const auto *t = std::get_if(&p)) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = t->text.c_str(); + } else { + const auto &r = std::get(p); + char *joined = trc_join(r); + a.joined = + (char **)xrealloc(a.joined, + (a.joined_n + 1) * sizeof *a.joined); + a.joined[a.joined_n++] = joined; + o->parts[k].kind = LLM_TOOL_RESULT; + o->parts[k].text = joined; + o->parts[k].name = r.name.c_str(); + o->parts[k].call = r.call.c_str(); + } + k++; + } + } else { + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + if (const auto *t = std::get_if(&p)) { + o->parts[k].kind = LLM_TEXT; + o->parts[k].text = t->text.c_str(); + } else if (const auto *r = std::get_if(&p)) { + o->parts[k].kind = LLM_REASONING; + o->parts[k].text = r->text.c_str(); + } else if (const auto *c = std::get_if(&p)) { + o->parts[k].kind = LLM_TOOL_CALL; + o->parts[k].name = c->name.c_str(); + o->parts[k].args = c->args.c_str(); + } + k++; + } + } + o->part_count = k; + } + return a; +} + +static void app_llm_msgs_free(app_llm_msgs *a) { + for (size_t i = 0; i < a->n; i++) + free(a->msgs[i].parts); + free(a->msgs); + for (size_t i = 0; i < a->joined_n; i++) + free(a->joined[i]); + free(a->joined); + memset(a, 0, sizeof *a); +} + +/* Bridge the session's emit callback onto llm.h's primitive emitter. */ +typedef struct { + sess_emit_cb cb; + void *user; +} emit_user; + +static int emit_bridge(llm_provider prov, llm_emit_kind kind, + const char *delta, void *ud) { + (void)prov; + emit_user *eu = (emit_user *) ud; + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = (kind == LLM_EMIT_TEXT) ? SESS_EMIT_TEXT : SESS_EMIT_REASONING; + ev.text = xstrdup(delta); + int rc = eu->cb ? eu->cb(&ev, eu->user) : 0; + sess_emit_free(&ev); /* creator owns the payload; cb only reads it */ + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Tool execution */ +/* ------------------------------------------------------------------ */ + +static bool tool_allowed(const char *name, const strlist *allowed); + +static const char *required_string(const cJSON *args, const char *key) { + const cJSON *v = args ? cJSON_GetObjectItemCaseSensitive(args, key) : NULL; + return cJSON_IsString(v) && v->valuestring ? v->valuestring : NULL; +} + +static char *bad_tool_arg(const char *tool, const char *arg, char **err) { + if (err) + *err = xstrfmt("%s requires string `%s`", tool, arg); + return NULL; +} + +static char *exec_builtin(const session *s, const char *name, + const char *args_json, unsigned long shell_timeout, + char **err) { + const dotagents *dot = s ? session_dot(s) : NULL; + const config *cfg = s ? session_config(s) : NULL; + if (err) + *err = NULL; + cJSON *a = cJSON_Parse(args_json ? args_json : "{}"); + if (!cJSON_IsObject(a)) { + cJSON_Delete(a); + if (err) + *err = xstrdup("tool arguments must be a JSON object"); + return NULL; + } + char *out = NULL; + if (xstreq(name, "read_file")) { + if (!required_string(a, "path")) { + out = bad_tool_arg(name, "path", err); + goto done; + } + unsigned long off = 0, lim = 0; + const cJSON *ov = cJSON_GetObjectItem(a, "offset"); + const cJSON *lv = cJSON_GetObjectItem(a, "limit"); + if (ov && (!cJSON_IsNumber(ov) || !std::isfinite(ov->valuedouble) || + ov->valuedouble < 1 || std::floor(ov->valuedouble) != ov->valuedouble || + ov->valuedouble >= (double)std::numeric_limits::max())) { + out = bad_tool_arg(name, "offset (positive integer)", err); + goto done; + } + if (ov) + off = (unsigned long)ov->valuedouble; + if (lv && (!cJSON_IsNumber(lv) || !std::isfinite(lv->valuedouble) || + lv->valuedouble < 1 || std::floor(lv->valuedouble) != lv->valuedouble || + lv->valuedouble >= (double)std::numeric_limits::max())) { + out = bad_tool_arg(name, "limit (positive integer)", err); + goto done; + } + if (lv) + lim = (unsigned long)lv->valuedouble; + out = tool_read_file(jstr(a, "path"), off, lim, err); + } else if (xstreq(name, "write_file")) { + if (!required_string(a, "path") || !required_string(a, "content")) { + out = bad_tool_arg(name, + required_string(a, "path") ? "content" : "path", + err); + goto done; + } + out = tool_write_file(required_string(a, "path"), + required_string(a, "content"), err); + } else if (xstreq(name, "run_shell")) { + const char *command = required_string(a, "command"); + if (!command || !command[0]) { + out = bad_tool_arg(name, "command", err); + goto done; + } + const tool_limits *lim = tool_limits_current(); + if (shell_gate(command, lim, err) == 0) + out = tool_run_shell_timed(command, shell_timeout, err); + } else if (xstreq(name, "use_skill")) { + if (!required_string(a, "id")) { + out = bad_tool_arg(name, "id", err); + goto done; + } + out = tool_use_skill(dot, required_string(a, "id"), err); + } else if (xstreq(name, "search_memories")) { + if (!required_string(a, "query")) { + out = bad_tool_arg(name, "query", err); + goto done; + } + out = tool_search_memories(dot, required_string(a, "query"), err); + } else if (xstreq(name, "embed")) { + if (!required_string(a, "text")) { + out = bad_tool_arg(name, "text", err); + goto done; + } + out = tool_embed(required_string(a, "text"), cfg, dot, err); + } else if (xstreq(name, "llm")) { + llm_tool_call call; + memset(&call, 0, sizeof call); + call.temperature = -1.0; + call.prompt = required_string(a, "prompt"); + call.model = jstr(a, "model"); + const cJSON *tv = cJSON_GetObjectItem(a, "temperature"); + if (tv && (!cJSON_IsNumber(tv) || !std::isfinite(tv->valuedouble) || + tv->valuedouble < 0 || tv->valuedouble > 2)) { + out = bad_tool_arg(name, "temperature (0..2)", err); + goto done; + } + if (tv) + call.temperature = tv->valuedouble; + const cJSON *mv = cJSON_GetObjectItem(a, "max_tokens"); + if (mv && (!cJSON_IsNumber(mv) || !std::isfinite(mv->valuedouble) || + mv->valuedouble < 1 || std::floor(mv->valuedouble) != mv->valuedouble || + mv->valuedouble >= (double)std::numeric_limits::max())) { + out = bad_tool_arg(name, "max_tokens (positive integer)", err); + goto done; + } + if (mv) + call.max_tokens = (unsigned long)mv->valuedouble; + call.reasoning_effort = jstr(a, "reasoning_effort"); + const cJSON *bv = cJSON_GetObjectItem(a, "thinking_budget"); + if (bv && (!cJSON_IsNumber(bv) || !std::isfinite(bv->valuedouble) || + bv->valuedouble < 1 || std::floor(bv->valuedouble) != bv->valuedouble || + bv->valuedouble >= (double)std::numeric_limits::max())) { + out = bad_tool_arg(name, "thinking_budget (positive integer)", err); + goto done; + } + if (bv) + call.thinking_budget = (unsigned long)bv->valuedouble; + const cJSON *sv = cJSON_GetObjectItem(a, "response_schema"); + char *schema_ser = NULL; + if (sv) { + if (cJSON_IsString(sv) && sv->valuestring) + call.response_schema = sv->valuestring; + else { + schema_ser = cJSON_PrintUnformatted(sv); + call.response_schema = schema_ser; + } + } + const cJSON *ev = cJSON_GetObjectItem(a, "embed"); + if (ev && cJSON_IsBool(ev)) + call.embed = cJSON_IsTrue(ev); + if (!cfg) { + if (err) + *err = xstrdup("llm: no session config"); + } else { + out = tool_llm(&call, cfg, dot, err); + } + free(schema_ser); + } else if (xstreq(name, "compact")) { + compact_report rep; + memset(&rep, 0, sizeof rep); + int rc = session_force_compact((session *)s, &rep, err); + if (rc > 0) { + session_print_compact(&rep); + out = xstrfmt("compacted: %lu -> %lu tokens", rep.tokens_before, + rep.tokens_after); + } else if (rc == 0) { + out = xstrdup("history too short to compact"); + } + } else { + if (err) + *err = xstrfmt("unknown tool `%s`", name); + } +done: + cJSON_Delete(a); + return out; +} + +static char *exec_tool(const provider_model *p, const char *name, + const char *args_json, unsigned long shell_timeout, + char **err) { + const strlist *allowed = session_allowed_tools(p->sess); + if (!tool_allowed(name, allowed)) { + if (err) + *err = xstrfmt("tool `%s` is not allowed by the active profile", name); + return NULL; + } + if (p->mcp) { + for (size_t i = 0; i < p->mcp->tool_count; i++) + if (xstreq(p->mcp->tools[i].qualified_name, name)) + return mcp_tool_invoke(&p->mcp->tools[i], args_json, err); + } + return exec_builtin(p->sess, name, args_json, shell_timeout, err); +} + +/* ------------------------------------------------------------------ */ +/* Tool schemas (registry -> `"tools":[...]` fragment) */ +/* ------------------------------------------------------------------ */ + +static bool tool_allowed(const char *name, const strlist *allowed) { + if (!allowed || allowed->len == 0) + return true; + for (size_t i = 0; i < allowed->len; i++) + if (xstreq(allowed->items[i], name)) + return true; + return false; +} + +/* FNV-1a over (provider, allowed tools, mcp tool names) -- the cache key + * for the tool-schema fragment. */ +static unsigned long tfv(unsigned long h, const char *s) { + for (const char *c = s; *c; c++) + h = (h ^ (unsigned char)*c) * 1099511628211UL; + return (h ^ 0xff) * 1099511628211UL; +} + +static unsigned long tools_fingerprint(const config *cfg, + const strlist *allowed, + mcp_registry *mcp) { + unsigned long h = 1469598103934665603UL; + h = (h ^ (unsigned long)cfg->provider) * 1099511628211UL; + if (allowed) + for (size_t i = 0; i < allowed->len; i++) + h = tfv(h, allowed->items[i]); + if (mcp) + for (size_t i = 0; i < mcp->tool_count; i++) + h = tfv(h, mcp->tools[i].qualified_name); + return h; +} + +/* The provider-shaped `"tools":[...]` fragment, cached on the model. + * Rebuilt only when the fingerprint (provider, allowed list, mcp tools) + * changes. Borrowed from `p`; valid until provider_destroy. */ +static const char *tools_fragment(provider_model *p, const config *cfg, + const strlist *allowed) { + unsigned long key = tools_fingerprint(cfg, allowed, p->mcp); + if (p->tools_json && p->tools_key == key) + return p->tools_json; + free(p->tools_json); + p->tools_json = NULL; + p->tools_key = key; + + xbuf frag; + xbuf_init(&frag); + xbuf_put(&frag, "\"tools\":["); + bool first = true; + for (size_t i = 0; i < tool_builtin_count(); i++) { + const char *name = tool_builtin_name(i); + if (!tool_allowed(name, allowed)) + continue; + cJSON *t; + if (cfg->provider == PROV_ANTHROPIC) { + t = cJSON_CreateObject(); + cJSON_AddStringToObject(t, "name", name); + cJSON_AddStringToObject(t, "description", tool_desc(name)); + cJSON_AddItemToObject(t, "input_schema", tool_params_json(name)); + } else { + t = cJSON_CreateObject(); + cJSON_AddStringToObject(t, "type", "function"); + cJSON *f = cJSON_AddObjectToObject(t, "function"); + cJSON_AddStringToObject(f, "name", name); + cJSON_AddStringToObject(f, "description", tool_desc(name)); + cJSON_AddItemToObject(f, "parameters", tool_params_json(name)); + } + char *ts = cJSON_PrintUnformatted(t); + cJSON_Delete(t); + if (!first) + xbuf_push(&frag, ','); + xbuf_put(&frag, ts ? ts : "{}"); + free(ts); + first = false; + } + if (p->mcp) { + for (size_t i = 0; i < p->mcp->tool_count; i++) { + const mcp_tool *mt = &p->mcp->tools[i]; + if (!tool_allowed(mt->qualified_name, allowed)) + continue; + cJSON *t = cJSON_CreateObject(); + cJSON *schema = mt->input_schema ? cJSON_Parse(mt->input_schema) + : NULL; + if (!schema) { + schema = cJSON_CreateObject(); + cJSON_AddStringToObject(schema, "type", "object"); + } + if (cfg->provider == PROV_ANTHROPIC) { + cJSON_AddStringToObject(t, "name", mt->qualified_name); + if (mt->description) + cJSON_AddStringToObject(t, "description", mt->description); + cJSON_AddItemToObject(t, "input_schema", schema); + } else { + cJSON_AddStringToObject(t, "type", "function"); + cJSON *fn = cJSON_AddObjectToObject(t, "function"); + cJSON_AddStringToObject(fn, "name", mt->qualified_name); + if (mt->description) + cJSON_AddStringToObject(fn, "description", mt->description); + cJSON_AddItemToObject(fn, "parameters", schema); + } + char *ts = cJSON_PrintUnformatted(t); + cJSON_Delete(t); + if (!first) + xbuf_push(&frag, ','); + xbuf_put(&frag, ts ? ts : "{}"); + free(ts); + first = false; + } + } + xbuf_push(&frag, ']'); + p->tools_json = xbuf_steal(&frag); + xbuf_free(&frag); + return p->tools_json; +} + +/* ------------------------------------------------------------------ */ +/* complete (summarizer / evaluator) */ +/* ------------------------------------------------------------------ */ + +static char *provider_complete(const llsh_model *mm, const char *prompt, + sess_error *err) { + provider_model *p = PCAST(mm); + if (err) + *err = (sess_error){0}; + if (!p->sess) { + if (err) + err->message = xstrdup("provider model not attached to a session"); + return NULL; + } + const char *preamble; + if (p->role == PROV_ROLE_SUMMARIZER) + preamble = compact_summarizer_preamble(); + else if (p->role == PROV_ROLE_EVALUATOR) + preamble = goal_evaluator_preamble(); + else if (p->role == PROV_ROLE_RUBRIC) + preamble = compact_rubric_preamble(); + else + preamble = session_current_preamble(p->sess); + + /* single user message carrying the prompt */ + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = prompt ? prompt : ""; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + llm_conn conn = conn_of(p->sess); + char *req = llm_build_request(&conn, preamble, &one, 1, NULL, false); + char *url = llm_url(&conn); + long status = 0; + char *body = NULL, *headers = NULL, *herr = NULL; + int hrc = + llm_post(&conn, url, req, &status, &body, &headers, &herr, NULL, + &p->curl); + free(req); + free(url); + + if (hrc != 0) { + if (err) { + err->message = herr ? herr : xstrdup("transport error"); + err->http_status = 0; + err->transport = 1; + } + free(body); + free(headers); + return NULL; + } + char *result = NULL; + llm_completion c; + memset(&c, 0, sizeof c); + if (llm_parse_body(&conn, body, &c) == 0) { + if (c.text && c.text[0]) + result = c.text; + else + result = xstrdup(""); + c.text = NULL; + llm_completion_free(&c); + } + if (status < 200 || status >= 300 || !result) { + char *em = llm_parse_error(body ? body : "", status); + if (err) { + err->message = em; + err->http_status = (int)status; + err->transport = 0; + err->retry_after_secs = llm_header_retry_after(headers); + } + free(result); + result = NULL; + } + free(body); + free(headers); + return result; +} + +/* ------------------------------------------------------------------ */ +/* stream (agent turn) */ +/* ------------------------------------------------------------------ */ + +void provider_trim_dangling_calls(message *msgs, size_t n) { + for (size_t i = 0; i < n; i++) { + message *m = &msgs[i]; + if (!m->is_assistant() || m->assistant().parts.empty()) + continue; + size_t calls = 0; + for (const auto &p : m->assistant().parts) + if (std::holds_alternative(p)) + calls++; + if (calls == 0) + continue; + size_t results = 0; + for (size_t k = i + 1; k < n; k++) { + message *u = &msgs[k]; + if (u->is_user() && !u->user().parts.empty() && + std::holds_alternative( + u->user().parts[0])) + results++; + else + break; + } + if (results >= calls) + continue; + size_t kept = 0; + auto &parts = m->assistant().parts; + for (size_t j = 0; j < parts.size(); j++) { + if (std::holds_alternative(parts[j])) { + if (kept >= results) { + parts.erase(parts.begin() + static_cast(j)); + j--; + } else { + kept++; + } + } + } + } +} + +static int provider_stream(const llsh_model *mm, + const message *history, size_t nhistory, + const char *prompt, sess_emit_cb cb, void *user, + std::atomic *stop, char **assistant_text, + unsigned long *input_tokens, message **new_messages, + size_t *n_new, sess_error *err) { + provider_model *p = PCAST(mm); + if (assistant_text) + *assistant_text = NULL; + if (input_tokens) + *input_tokens = 0; + if (new_messages) + *new_messages = NULL; + if (n_new) + *n_new = 0; + if (err) + *err = (sess_error){0}; + + if (!p->sess) { + if (err) + err->message = xstrdup("provider model not attached to a session"); + return SESS_STREAM_FAILED; + } + + const config *cfg = session_active_config(p->sess); + const char *preamble = session_current_preamble(p->sess); + const strlist *allowed = session_allowed_tools(p->sess); + const unsigned long max_turns = cfg->max_turns; /* 0 = unbounded */ + llm_conn conn = conn_of(p->sess); + emit_user eu = {cb, user}; + + /* local working messages for this turn (assistant tool calls + results) */ + std::vector local; + int rc = SESS_STREAM_OK; + unsigned long last_in = 0; + char *final_text = NULL; + char *final_reasoning = NULL; + size_t call_counter = 0; + bool stopped = false; + + for (unsigned long turn = 0; max_turns == 0 || turn < max_turns; turn++) { + if (stop && stop->load()) { + stopped = true; + break; + } + /* combined = history + [prompt] + local */ + message pm = msg_user_text(prompt ? prompt : ""); + size_t total = nhistory + 1 + local.size(); + const message **all = (const message **)xmalloc(total * sizeof *all); + for (size_t i = 0; i < nhistory; i++) + all[i] = &history[i]; + all[nhistory] = ± + for (size_t i = 0; i < local.size(); i++) + all[nhistory + 1 + i] = &local[i]; + + app_llm_msgs am = app_to_llm(all, total); + free(all); + const char *tools = tools_fragment(p, cfg, allowed); + char *req = + llm_build_request(&conn, preamble, am.msgs, am.n, tools, true); + app_llm_msgs_free(&am); + char *url = llm_url(&conn); + long status = 0; + char *body = NULL, *headers = NULL, *herr = NULL; + bool stopped_post = false; + llm_sse_acc acc; + llm_sse_acc_init(&acc, conn.provider); + int hrc = llm_post_stream(&conn, url, req, &status, &body, &headers, + &acc, emit_bridge, &eu, stop, &stopped_post, + &herr, &p->curl); + free(req); + free(url); + + if (hrc != 0) { + /* A mid-stream transport drop is retryable and the partial + * response is discarded -- but the user has already seen the + * streamed text, so a retry re-shows it. Accepted (AUDIT). */ + if (err) { + err->message = herr ? herr : xstrdup("transport error"); + err->http_status = 0; + err->transport = 1; + } + free(body); + free(headers); + llm_sse_acc_free(&acc); + if (stopped_post) + stopped = true; /* user abort: not a retryable failure */ + else + rc = SESS_STREAM_RETRYABLE; + break; + } + if (status < 200 || status >= 300) { + char *em = llm_parse_error(body ? body : "", status); + bool transient = sess_is_transient_status(status); + rc = transient ? SESS_STREAM_RETRYABLE : SESS_STREAM_FAILED; + if (err) { + err->message = em; + err->http_status = (int)status; + err->transport = 0; + err->retry_after_secs = llm_header_retry_after(headers); + } + free(body); + free(headers); + llm_sse_acc_free(&acc); + break; + } + + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&acc, &c); + bool live = llm_sse_acc_had_content(&acc); + /* Fallback for endpoints that ignore `stream:true` and answer with + * one complete JSON body: parse it the non-streaming way; the text + * was not emitted live, so it is emitted below. */ + if (!live && body && body[0] == '{') + llm_parse_body(&conn, body, &c); + free(body); + free(headers); + llm_sse_acc_free(&acc); + + if (c.input_tokens) + last_in = c.input_tokens; + + /* emit reasoning (chain of thought), if the model returned one and + * it did not already stream live */ + if (!live && c.reasoning && c.reasoning[0]) { + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("reasoning", c.reasoning, NULL, &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + break; + } + sess_emit_free(&ev); + } + } + + /* emit text, unless it already streamed live */ + if (!live && c.text && c.text[0]) { + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("text", c.text, NULL, &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + break; + } + sess_emit_free(&ev); + } + } + + if (c.tc_len == 0) { + /* final answer: take ownership of c.text and reasoning */ + final_text = c.text ? c.text : xstrdup(""); + c.text = NULL; + final_reasoning = c.reasoning ? c.reasoning : NULL; + c.reasoning = NULL; + llm_completion_free(&c); + break; + } + + /* tool calls: build the assistant message + execute + tool results */ + message amsg; + if (c.reasoning && c.reasoning[0]) + msg_push_assistant_reasoning(amsg, c.reasoning); + if (c.text && c.text[0]) + msg_push_assistant_text(amsg, c.text); + for (size_t t = 0; t < c.tc_len; t++) + msg_push_assistant_tool_call(amsg, c.tc_name[t], c.tc_args[t]); + local.push_back(std::move(amsg)); + + for (size_t t = 0; t < c.tc_len; t++) { + if (stop && stop->load()) { + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + /* emit the tool call */ + sess_emit ev; + memset(&ev, 0, sizeof ev); + if (sess_assistant_emit("tool_call", c.tc_name[t], c.tc_args[t], + &ev) == 0) { + if (cb && cb(&ev, user)) { + sess_emit_free(&ev); + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + sess_emit_free(&ev); + } + /* execute */ + char *tresult = NULL, *terr = NULL; + tresult = exec_tool(p, c.tc_name[t], c.tc_args[t], + cfg->shell_timeout_secs, &terr); + if (!tresult) { + tresult = terr ? terr : xstrdup("tool failed"); + } else if (terr) { + free(terr); /* exec_tool: result and error are exclusive */ + } + char *bounded = tool_truncate_output(tresult); + free(tresult); + tresult = bounded; + /* emit the tool result */ + { + sess_emit ev2; + memset(&ev2, 0, sizeof ev2); + if (sess_tool_result_emit(c.tc_name[t], tresult, &ev2) == 0) { + if (cb && cb(&ev2, user)) { + sess_emit_free(&ev2); + stopped = true; + llm_completion_free(&c); + goto end_loop; + } + sess_emit_free(&ev2); + } + } + /* tool result message */ + call_counter++; + char *id = c.tc_id[t] ? xstrdup(c.tc_id[t]) + : xstrfmt("call-%d", (int)call_counter); + local.push_back(msg_user_tool_result(id, c.tc_name[t], tresult)); + free(id); + free(tresult); + } + /* One tool round completed: count it for the rubric gates. */ + if (p->sess) + session_note_tool_round(p->sess); + llm_completion_free(&c); + } +end_loop: + if (input_tokens) + *input_tokens = last_in; + + if (stopped && !local.empty()) + provider_trim_dangling_calls(local.data(), local.size()); + + /* build new_messages = [user_text(prompt)] + local */ + { + std::vector nm; + nm.push_back(msg_user_text(prompt ? prompt : "")); + for (size_t i = 0; i < local.size(); i++) + nm.push_back(local[i]); + if ((final_text && final_text[0]) || + (final_reasoning && final_reasoning[0])) { + message fm; + if (final_reasoning && final_reasoning[0]) + msg_push_assistant_reasoning(fm, final_reasoning); + if (final_text && final_text[0]) + msg_push_assistant_text(fm, final_text); + nm.push_back(std::move(fm)); + } + if (new_messages) { + *new_messages = new message[nm.size()]; + for (size_t i = 0; i < nm.size(); i++) + (*new_messages)[i] = std::move(nm[i]); + } + if (n_new) + *n_new = nm.size(); + } + if (assistant_text) + *assistant_text = xstrdup(final_text ? final_text : ""); + + free(final_text); + free(final_reasoning); + + if (stopped) + rc = SESS_STREAM_STOPPED; + + return rc; +} + +/* ------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------ */ + +static void provider_destroy(llsh_model *base) { + provider_model *p = PCAST(base); + free(p->tools_json); + if (p->curl) + curl_easy_cleanup(p->curl); + free(p); +} + +llsh_model *provider_model_new(session *sess, mcp_registry *mcp, + provider_role role) { + provider_model *p = (provider_model *) xmalloc(sizeof *p); + memset(p, 0, sizeof *p); + p->sess = sess; + p->mcp = mcp; + p->role = role; + p->base.complete = provider_complete; + p->base.stream = provider_stream; + p->base.destroy = provider_destroy; + return &p->base; +} + +void provider_model_attach(llsh_model *m, session *sess) { + PCAST(m)->sess = sess; +} + +char *provider_preview_request(session *sess, const char *prompt, + bool with_tools) { + llsh_model *mm = provider_model_new(sess, NULL, PROV_ROLE_MAIN); + provider_model *p = PCAST(mm); + const config *cfg = session_active_config(sess); + const char *preamble = session_current_preamble(sess); + const strlist *allowed = session_allowed_tools(sess); + + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = prompt ? prompt : ""; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + llm_conn conn = conn_of(sess); + const char *tools = with_tools ? tools_fragment(p, cfg, allowed) : NULL; + char *req = llm_build_request(&conn, preamble, &one, 1, tools, false); + llsh_model_free(mm); + return req; +} diff --git a/src/provider.h b/src/provider.h new file mode 100644 index 0000000..ae60896 --- /dev/null +++ b/src/provider.h @@ -0,0 +1,41 @@ +#ifndef LLSH_PROVIDER_H +#define LLSH_PROVIDER_H + +#include + +#include "llm.h" +#include "mcp.h" +#include "session.h" + +/* The session-bound provider layer: implements the `llsh_model` vtable on + * top of llm.h's protocol layer, wiring in the session (config, preamble, + * allowed tools, .agents), the built-in + MCP tool registry, and the + * session's emit callback. */ + +/* Which one-shot role this model serves. `complete` uses the matching + * preamble; `stream` (the agent turn) always uses the session preamble. */ +typedef enum { + PROV_ROLE_MAIN = 0, + PROV_ROLE_SUMMARIZER, + PROV_ROLE_EVALUATOR, + PROV_ROLE_RUBRIC, +} provider_role; + +/* Create a session-bound model. `sess` may be NULL until + * provider_model_attach; `mcp` may be NULL (no MCP tools). */ +llsh_model *provider_model_new(session *sess, mcp_registry *mcp, + provider_role role); +void provider_model_attach(llsh_model *m, session *sess); + +/* Test hook: build the request body (without sending) for the session's + * current config, preamble, and tool set, for a single one-shot prompt. + * Caller frees the returned string. */ +char *provider_preview_request(session *sess, const char *prompt, + bool with_tools); + +/* On stop, an assistant message can carry more tool calls than the + * tool-result messages that follow it (we stopped mid-loop). Trim the + * surplus calls so the committed history stays a valid request. */ +void provider_trim_dangling_calls(message *msgs, size_t n); + +#endif /* LLSH_PROVIDER_H */ diff --git a/src/session.cpp b/src/session.cpp new file mode 100644 index 0000000..7e82173 --- /dev/null +++ b/src/session.cpp @@ -0,0 +1,1555 @@ +#include "session.h" +#include +#include +#ifndef LLSH_DBG +#define LLSH_DBG() (getenv("LLSH_DBG")!=NULL) +#define dbgf(...) do{ if(LLSH_DBG()) fprintf(stderr, __VA_ARGS__); }while(0) +#endif + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "escape.h" + +#include "util.h" +#include "config.h" +#include "dotagents.h" +#include "message.h" +#include "compact.h" +#include "goal.h" +#include "agent.h" +#include "tools.h" +#include "llm.h" + +/* ANSI colors (session.rs), from escape.h. */ +#define DIM ESC_DIM +#define RESET ESC_RESET +#define RED ESC_COLOR_RED +#define YELLOW ESC_COLOR_YELLOW + +#define SNAPSHOT_VERSION 1UL + +/* ------------------------------------------------------------------ */ +/* Small local helpers */ +/* ------------------------------------------------------------------ */ + +static bool trimmed_nonempty(const char *s) { + if (!s) + return false; + while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') + s++; + return *s != 0; +} + +static const char *json_str(const cJSON *v, const char *key) { + const cJSON *item = cJSON_GetObjectItem(v, key); + if (cJSON_IsString(item) && item->valuestring) + return item->valuestring; + return ""; +} + +static config config_clone(const config *c) { + config out; + out.provider = c->provider; + out.model = c->model ? xstrdup(c->model) : NULL; + out.base_url = c->base_url ? xstrdup(c->base_url) : NULL; + out.api_key = c->api_key ? xstrdup(c->api_key) : NULL; + out.preamble = c->preamble ? xstrdup(c->preamble) : NULL; + out.max_turns = c->max_turns; + out.max_retries = c->max_retries; + out.context_limit = c->context_limit; + out.compact_threshold = c->compact_threshold; + out.clear_threshold = c->clear_threshold; + out.verbose = c->verbose; + out.skill_index_cap = c->skill_index_cap; + out.shell_timeout_secs = c->shell_timeout_secs; + out.max_output_chars = c->max_output_chars; + out.max_file_bytes = c->max_file_bytes; + sl_init(&out.shell_allowlist); + for (size_t i = 0; i < c->shell_allowlist.len; i++) + sl_push(&out.shell_allowlist, xstrdup(c->shell_allowlist.items[i])); + sl_init(&out.shell_denylist); + for (size_t i = 0; i < c->shell_denylist.len; i++) + sl_push(&out.shell_denylist, xstrdup(c->shell_denylist.items[i])); + out.confirm_destructive = c->confirm_destructive; + return out; +} + +/* ------------------------------------------------------------------ */ +/* Free helpers */ +/* ------------------------------------------------------------------ */ + +void sess_emit_free(sess_emit *e) { + if (!e) + return; + free(e->text); + e->text = NULL; +} + +void sess_error_clear(sess_error *e) { + if (!e) + return; + free(e->message); + e->message = NULL; + e->http_status = 0; + e->transport = 0; + e->retry_after_secs = 0; +} + +void sess_retry_free(sess_retry *r) { + if (!r) + return; + free(r->reason); + free(r); +} + +/* ------------------------------------------------------------------ */ +/* Pure helpers */ +/* ------------------------------------------------------------------ */ + +bool sess_is_transient_status(int status) { + return status == 408 || status == 429 || status == 500 || status == 502 || + status == 503 || status == 504 || status == 529; +} + +unsigned long sess_backoff_secs(unsigned long attempt) { + unsigned long exp = attempt > 1 ? attempt - 1 : 0; + if (exp > 5) + exp = 5; + unsigned long ms = 1000UL << exp; + if (ms > 30000) + ms = 30000; + return ms / 1000; +} + +unsigned long sess_human_secs(unsigned long secs) { + return secs < 1 ? 1 : secs; +} + +sess_retry *sess_classify_retry(const sess_error *e) { + if (!e) + return NULL; + if (e->http_status) { + if (sess_is_transient_status(e->http_status)) { + sess_retry *r = (sess_retry *) xmalloc(sizeof *r); + r->reason = xstrfmt("HTTP %d", e->http_status); + unsigned long ra = e->retry_after_secs; + if (ra > 60) + ra = 60; + r->retry_after_secs = ra; + r->has_retry_after = ra > 0; + return r; + } + return NULL; + } + if (e->transport) { + sess_retry *r = (sess_retry *) xmalloc(sizeof *r); + r->reason = xstrdup("transient connection error"); + r->retry_after_secs = 0; + r->has_retry_after = false; + return r; + } + return NULL; +} + +bool sess_error_is_context_overflow(const sess_error *e) { + if (!e || !e->message) + return false; + if (e->http_status == 413) + return true; + if (!e->http_status || e->http_status < 400 || e->http_status >= 500) + return false; + if (llm_strcasestr(e->message, "context") || + llm_strcasestr(e->message, "exceed")) + return true; + return false; +} + +int sess_assistant_emit(const char *kind, const char *text, const char *args, + sess_emit *out) { + if (xstreq(kind, "text")) { + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_TEXT; + out->text = text ? xstrdup(text) : NULL; + } + return 0; + } + if (xstreq(kind, "reasoning")) { + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_REASONING; + out->text = text ? xstrdup(text) : NULL; + } + return 0; + } + if (xstreq(kind, "tool_call")) { + /* One-line transcript display. Tools with a custom renderer in the + * display vtable (e.g. run_shell -> `$ `) use it; everything + * else falls back to `# name `, where is the raw JSON the + * model sent (tool params are a JSON object). `# name` is yellow, the + * args are dimmed. Presentation only -- the call is executed directly + * by exec_builtin. */ + const char *name = text ? text : ""; + const char *a = (args && args[0]) ? args : NULL; + char *body = NULL; + const tool_display *op = tool_display_for(name); + if (op && op->render_call) + body = op->render_call(a); + if (!body) + body = a + ? xstrfmt("\n%s# %s%s %s%s%s\n", YELLOW, name, RESET, DIM, + a, RESET) + : xstrfmt("\n%s# %s%s\n", YELLOW, name, RESET); + if (out) { + *out = (sess_emit){0}; + out->kind = SESS_EMIT_VERBATIM; + out->text = body; + } else + free(body); + return 0; + } + return 1; +} + +int sess_tool_result_emit(const char *tool, const char *result, + sess_emit *out) { + if (!result || !result[0] || !out) + return 1; + size_t len = strlen(result); + while (len > 0 && result[len - 1] == '\n') + len--; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, result, len); + xbuf_push(&b, '\n'); + char *body = xbuf_steal(&b); + xbuf_free(&b); + *out = (sess_emit){0}; + out->kind = SESS_EMIT_RESULT; + out->text = body; + /* Shell-style tools (per the display vtable) render their results + * muted; other tool results stay in the normal color. */ + const tool_display *op = tool ? tool_display_for(tool) : NULL; + out->from_shell = op != NULL && op->dim_result; + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Preamble / preset / profile helpers */ +/* ------------------------------------------------------------------ */ + +char *session_active_preset_from(const dotagents *dot) { + if (dot && dot->has_models && dot->models.default_name && + dot->models.default_name[0]) + return xstrdup(dot->models.default_name); + return NULL; +} + +char *sess_base_preamble_for(const config *cfg, const dotagents *dot) { + const char *base = trimmed_nonempty(dot->system_prompt) + ? dot->system_prompt + : (cfg->preamble ? cfg->preamble : ""); + return dotagents_compose_preamble(dot, base, cfg->skill_index_cap); +} + +char *sess_preamble_for_profile(const dotagents *dot, const config *cfg, + const char *profile) { + if (profile) { + const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); + if (a) { + char *body = xstr_rtrim_dup(a->body ? a->body : ""); + char *sections = dotagents_project_sections(dot, cfg->skill_index_cap); + char *r = xstrjoin2(body, sections); + free(body); + free(sections); + return r; + } + } + return sess_base_preamble_for(cfg, dot); +} + +int sess_apply_overrides(config *cfg, const char *model, const char *provider, + const char *base_url, const char *api_key, + bool strict, char **err) { + if (provider) { + char *perr = NULL; + provider_kind pk = provider_parse(provider, &perr); + if (perr) { + if (strict) { + if (err) + *err = perr; + else + free(perr); + return -1; + } + free(perr); + } else { + cfg->provider = pk; + } + } + if (model) { + free(cfg->model); + cfg->model = xstrdup(model); + } + if (base_url) { + free(cfg->base_url); + cfg->base_url = xstrdup(base_url); + } + if (api_key) { + free(cfg->api_key); + cfg->api_key = xstrdup(api_key); + } + return 0; +} + +config sess_effective_for_profile(const config *cfg, const dotagents *dot, + const char *profile) { + config out = config_clone(cfg); + if (profile) { + const sub_agent *a = dotagents_enabled_sub_agent(dot, profile); + if (a && a->config) { + cJSON *root = cJSON_Parse(a->config); + if (root) { + const char *model = json_str(root, "model"); + const char *provider = json_str(root, "provider"); + const char *base_url = json_str(root, "base_url"); + const char *api_key = json_str(root, "api_key"); + if (provider[0]) { + char *perr = NULL; + provider_kind pk = provider_parse(provider, &perr); + if (perr) + free(perr); + else + out.provider = pk; + } + if (model[0]) { + free(out.model); + out.model = xstrdup(model); + } + if (base_url[0]) { + free(out.base_url); + out.base_url = xstrdup(base_url); + } + if (api_key[0]) { + free(out.api_key); + out.api_key = xstrdup(api_key); + } + cJSON_Delete(root); + } + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Session state */ +/* ------------------------------------------------------------------ */ + +struct session { + config cfg; + dotagents dot; + history hist; + char *base_preamble; + char *active_preset; + char *profile; + compact_config compact; + unsigned long last_context_tokens; + bool summary_attempted_this_turn; + /* Rubric compaction (SelfCompact) bookkeeping. */ + unsigned long total_rounds; /* completed tool rounds (the "iteration") */ + unsigned long rounds_since_probe; + unsigned long rubric_summaries; + char *debug_path; /* --debug: history is dumped here (NULL = off) */ + llsh_model *model; + llsh_model *summarizer; + llsh_model *goal_evaluator; + llsh_model *rubric; /* SelfCompact judge (PROV_ROLE_RUBRIC) */ + goal_report *goal; /* name avoids the C++ type/member clash */ + /* Cached effective state for (cfg, dot, profile). Rebuilt lazily; + * invalidated whenever any of the three changes. */ + config active; + bool active_valid; + char *active_preamble; + strlist active_allowed; +}; + +static void session_active_reset(session *s) { + if (s->active_valid) { + config_free(&s->active); + s->active_valid = false; + } + free(s->active_preamble); + s->active_preamble = NULL; + sl_free(&s->active_allowed); +} + +static void session_active_build(session *s) { + if (s->active_valid) + return; + s->active = sess_effective_for_profile(&s->cfg, &s->dot, s->profile); + s->active_preamble = sess_preamble_for_profile(&s->dot, &s->cfg, + s->profile); + if (s->profile) { + const sub_agent *a = dotagents_enabled_sub_agent(&s->dot, s->profile); + if (a && a->config) { + cJSON *root = cJSON_Parse(a->config); + if (root) { + cJSON *tools = cJSON_GetObjectItem(root, "tools"); + if (cJSON_IsArray(tools)) { + const cJSON *t = NULL; + cJSON_ArrayForEach(t, tools) + if (cJSON_IsString(t) && t->valuestring) + sl_push_copy(&s->active_allowed, t->valuestring); + } + cJSON_Delete(root); + } + } + } + s->active_valid = true; +} + +session *session_new_with_model(llsh_model *model, config cfg, dotagents dot) { + session *s = (session *) xmalloc(sizeof *s); + new (s) session{}; + s->model = model; + s->cfg = cfg; + s->dot = dot; + s->base_preamble = sess_base_preamble_for(&cfg, &dot); + s->active_preset = session_active_preset_from(&dot); + s->profile = NULL; + s->last_context_tokens = 0; + s->summary_attempted_this_turn = false; + s->total_rounds = 0; + s->rounds_since_probe = 0; + s->rubric_summaries = 0; + s->debug_path = NULL; + s->summarizer = NULL; + s->goal_evaluator = NULL; + s->goal = NULL; + compact_config_from(cfg.context_limit, cfg.compact_threshold, + cfg.clear_threshold, &s->compact); + return s; +} + +void session_free(session *s) { + if (!s) + return; + session_active_reset(s); + history_free(&s->hist); + free(s->debug_path); + free(s->base_preamble); + free(s->active_preset); + free(s->profile); + goal_report_free(s->goal); + llsh_model_free(s->model); + llsh_model_free(s->summarizer); + llsh_model_free(s->goal_evaluator); + llsh_model_free(s->rubric); + config_free(&s->cfg); + dotagents_free(&s->dot); + free(s); +} + +session *session_with_summarizer(session *s, llsh_model *summarizer) { + llsh_model_free(s->summarizer); + s->summarizer = summarizer; + return s; +} + +session *session_with_goal_evaluator(session *s, llsh_model *evaluator) { + llsh_model_free(s->goal_evaluator); + s->goal_evaluator = evaluator; + return s; +} + +session *session_with_rubric(session *s, llsh_model *rubric) { + llsh_model_free(s->rubric); + s->rubric = rubric; + return s; +} + +/* ------------------------------------------------------------------ */ +/* Accessors */ +/* ------------------------------------------------------------------ */ + +char *session_model(session *s) { + const config *cfg = session_active_config(s); + return xstrdup(cfg->model ? cfg->model : ""); +} + +const config *session_config(const session *s) { + return &s->cfg; +} + +char *session_active_preset(session *s) { + return s->active_preset ? xstrdup(s->active_preset) : NULL; +} + +char *session_profile_name(session *s) { + return s->profile ? xstrdup(s->profile) : NULL; +} + +const dotagents *session_dot(const session *s) { + return &s->dot; +} + +const strlist *session_allowed_tools(session *s) { + session_active_build(s); + return &s->active_allowed; +} + +unsigned long session_context_tokens(session *s) { + if (s->last_context_tokens > 0) + return s->last_context_tokens; + return compact_estimate_tokens(s->hist.msgs.data(), s->hist.msgs.size()); +} + +unsigned long session_context_limit(session *s) { + return s->compact.context_limit; +} + +const goal_report *session_goal_report(session *s) { + return s->goal; +} + +void session_set_goal_report(session *s, goal_report *r) { + goal_report_free(s->goal); + s->goal = r; +} + +char *session_last_assistant_text(session *s) { + for (size_t i = s->hist.msgs.size(); i-- > 0;) { + const message *m = &s->hist.msgs[i]; + if (!m->is_assistant()) + continue; + xbuf b; + xbuf_init(&b); + bool first = true; + for (size_t j = 0; j < m->assistant().parts.size(); j++) { + const auto &p = m->assistant().parts[j]; + const asst_text_part *t = std::get_if(&p); + if (t && !t->text.empty()) { + if (!first) + xbuf_push(&b, '\n'); + xbuf_put(&b, t->text.c_str()); + first = false; + } + } + char *text = xbuf_steal(&b); + xbuf_free(&b); + const char *p = text; + while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') + p++; + if (*p) + return text; + free(text); + } + return NULL; +} + +const char *session_current_preamble(session *s) { + session_active_build(s); + return s->active_preamble ? s->active_preamble : ""; +} + +const config *session_active_config(session *s) { + session_active_build(s); + return &s->active; +} + +int session_refresh_context_limit(session *s) { + unsigned long limit = agent_fetch_context_limit(&s->cfg); + if (limit > 0) { + s->cfg.context_limit = limit; + s->compact.context_limit = limit; + return 1; + } + return 0; +} + +/* ------------------------------------------------------------------ */ +/* History */ +/* ------------------------------------------------------------------ */ + +size_t session_history_len(session *s) { + return s->hist.msgs.size(); +} + +const message *session_history_at(session *s, size_t i) { + return i < s->hist.msgs.size() ? &s->hist.msgs[i] : NULL; +} + +void session_push_history(session *s, message m) { + s->hist.msgs.push_back(std::move(m)); +} + +unsigned long session_last_context_tokens(session *s) { + return s->last_context_tokens; +} + +void session_set_last_context_tokens(session *s, unsigned long t) { + s->last_context_tokens = t; +} + +void session_clear_history(session *s) { + s->hist.msgs.clear(); + s->last_context_tokens = 0; + s->total_rounds = 0; + s->rounds_since_probe = 0; + s->rubric_summaries = 0; +} + +int session_undo_last_turn(session *s) { + size_t n = s->hist.msgs.size(); + size_t last_user = compact_keep_boundary(s->hist.msgs.data(), n); + if (last_user >= n) + return -1; + size_t removed = n - last_user; + s->hist.msgs.erase(s->hist.msgs.begin() + static_cast(last_user), + s->hist.msgs.end()); + s->last_context_tokens = 0; + return (int)removed; +} + +size_t session_message_count(session *s) { + return s->hist.msgs.size(); +} + +char *session_transcript_markdown(session *s) { + return compact_transcript_markdown(s->hist.msgs.data(), s->hist.msgs.size()); +} + +/* ------------------------------------------------------------------ */ +/* Compaction */ +/* ------------------------------------------------------------------ */ + +void session_print_compact(const compact_report *report) { + const char *icon, *label; + if (report->tier == TIER_CLEARED) { + icon = "\xE2\x97\x88"; + label = "cleared stale tool output"; + } else if (report->tier == TIER_SUMMARIZED) { + icon = "\xE2\x97\x86"; + label = "summarized earlier context"; + } else { + icon = "!"; + label = "summarization failed; cleared stale tool output"; + } + char *details; + if (report->tier == TIER_SUMMARIZED) + details = xstrfmt("%zu file(s) re-injected", report->files_reinjected); + else + details = xstrfmt("%zu tool result(s) cleared", + report->cleared_tool_results); + char *line = xstrfmt("\n" DIM "%s %s: %lu \xE2\x86\x92 %lu tokens (%s)" RESET + "\n", + icon, label, report->tokens_before, + report->tokens_after, details); + free(details); + fputs(line, stdout); + fflush(stdout); + free(line); +} + +int session_compact_if_needed(session *s, bool force, compact_report *report, + char **err) { + dbgf("CFN enter force=%d n=%zu limit=%lu\n",(int)force,s->hist.msgs.size(),s->compact.context_limit); + (void)err; + size_t n = s->hist.msgs.size(); + if (n == 0) + return 0; + if (s->compact.context_limit == 0 && !force) + return 0; + + unsigned long tokens = s->last_context_tokens > 0 + ? s->last_context_tokens + : compact_estimate_tokens(s->hist.msgs.data(), n); + + if (!force) { + unsigned long cbudget, kbudget; + bool clear_due = clear_at(&s->compact, &cbudget) && tokens >= cbudget; + bool compact_due = + compact_at(&s->compact, &kbudget) && tokens >= kbudget; + if (!clear_due && !compact_due) + return 0; + } + + size_t boundary = compact_keep_boundary(s->hist.msgs.data(), n); + dbgf("CFN boundary=%zu tokens=%lu last_tok=%lu\n",boundary,tokens,s->last_context_tokens); + if (boundary == 0 || boundary >= n) + return 0; + + fputs("\n" RED "~ compaction" RESET "\n", stdout); + fflush(stdout); + + unsigned long kb; + bool want_summary = + (force || (compact_at(&s->compact, &kb) && tokens >= kb)) && + !s->summary_attempted_this_turn; + + dbgf("CFN want_summary=%d\n",(int)want_summary); + if (!want_summary) { + size_t cleared = + compact_clear_old_tool_results(s->hist.msgs.data(), n, boundary); + if (report) { + report->tier = TIER_CLEARED; + report->tokens_before = tokens; + report->tokens_after = + compact_estimate_tokens(s->hist.msgs.data(), n); + report->cleared_tool_results = cleared; + report->files_reinjected = 0; + } + s->last_context_tokens = 0; + return 1; + } + + /* stale = msgs[0..boundary); tail = msgs[boundary..n) */ + const message *stale = s->hist.msgs.data(); + + char *summary = NULL; + bool summary_ok = false; + if (s->summarizer) { + s->summary_attempted_this_turn = true; + char *transcript = compact_transcript(stale, boundary); + sess_error se = {0}; + sess_error_clear(&se); + summary = s->summarizer->complete(s->summarizer, transcript, &se); + dbgf("CFN summarizer called -> summary=%p\n",(void*)summary); + free(transcript); + if (summary) + summary_ok = true; + else + sess_error_clear(&se); + } + + dbgf("CFN summary_ok=%d\n",(int)summary_ok); + if (summary_ok) { + strlist paths; + sl_init(&paths); + compact_recent_file_paths(summary, &paths); + const char **file_paths = NULL; + const char **file_contents = NULL; + size_t nfiles = 0; + if (paths.len) { + file_paths = (const char* * ) xmalloc(paths.len * sizeof *file_paths); + file_contents = (const char* * ) xmalloc(paths.len * sizeof *file_contents); + } + for (size_t i = 0; i < paths.len; i++) { + char *content = NULL; + size_t clen = 0; + char *ferr = NULL; + if (read_file_to_buf(paths.items[i], &content, &clen, &ferr) == 0) { + file_paths[nfiles] = xstrdup(paths.items[i]); + file_contents[nfiles] = content; + nfiles++; + } + free(ferr); + } + sl_free(&paths); + + const message *tail = s->hist.msgs.data() + boundary; + history *newhist = compact_compose_history( + summary, file_paths, file_contents, nfiles, tail, n - boundary); + s->hist.msgs = std::move(newhist->msgs); + history_free(newhist); + dbgf("CFN composed newhist len=%zu\n",s->hist.msgs.size()); + free(newhist); + free(summary); + for (size_t i = 0; i < nfiles; i++) { + free((char *)file_paths[i]); + free((char *)file_contents[i]); + } + free(file_paths); + free(file_contents); + + if (report) { + report->tier = TIER_SUMMARIZED; + report->tokens_before = tokens; + report->tokens_after = compact_estimate_tokens( + s->hist.msgs.data(), s->hist.msgs.size()); + report->cleared_tool_results = 0; + report->files_reinjected = nfiles; + } + s->last_context_tokens = 0; + return 1; + } + + /* fallback: clear stale tool results in place */ + size_t cleared = compact_clear_old_tool_results(s->hist.msgs.data(), + boundary, boundary); + if (report) { + report->tier = TIER_FALLBACK_CLEAR; + report->tokens_before = tokens; + report->tokens_after = + compact_estimate_tokens(s->hist.msgs.data(), n); + report->cleared_tool_results = cleared; + report->files_reinjected = 0; + } + fprintf(stderr, DIM + "[compact] summarization failed; cleared stale tool results instead" + RESET "\n"); + s->last_context_tokens = 0; + return 1; +} + +/* ------------------------------------------------------------------ */ +/* Rubric compaction (SelfCompact, arXiv:2606.23525) */ +/* ------------------------------------------------------------------ */ + +void session_note_tool_round(session *s) { + if (!s) + return; + s->total_rounds++; + s->rounds_since_probe++; +} + +int session_force_compact(session *s, compact_report *report, char **err) { + if (!s) { + if (err) + *err = xstrdup("no session"); + return -1; + } + return session_compact_if_needed(s, true, report, err); +} + +int session_rubric_probe(session *s, char **err) { + if (err) + *err = NULL; + if (!s || !s->cfg.rubric_enabled || !s->rubric) + return 0; + + unsigned long tokens = s->last_context_tokens > 0 + ? s->last_context_tokens + : compact_estimate_tokens(s->hist.msgs.data(), + s->hist.msgs.size()); + if (!compact_rubric_due(s->total_rounds, tokens, s->rubric_summaries, + s->cfg.rubric_max_summaries, + s->rounds_since_probe)) + return 0; + + /* Probe the model with the whole trajectory plus the search rubric. */ + char *transcript = session_transcript_markdown(s); + if (!transcript) + return 0; + char *prompt = xstrfmt("%s\n\n%s", transcript, compact_rubric_prompt()); + free(transcript); + sess_error se = {0}; + sess_error_clear(&se); + char *answer = s->rubric->complete(s->rubric, prompt, &se); + free(prompt); + if (!answer) { + char *msg = xstrdup(se.message ? se.message : "rubric probe failed"); + sess_error_clear(&se); + if (err) + *err = msg; + else + free(msg); + return -1; + } + + compact_rubric r; + memset(&r, 0, sizeof r); + char *perr = NULL; + int prc = compact_rubric_parse(answer, &r, &perr); + free(answer); + if (prc != 0) { + char *msg = xstrdup(perr ? perr : "unparseable rubric verdict"); + free(perr); + if (err) + *err = msg; + else + free(msg); + return -1; + } + + /* A probe was made: restart the round gate regardless of the verdict. */ + s->rounds_since_probe = 0; + if (!compact_rubric_fire(&r)) + return 0; + + compact_report report; + memset(&report, 0, sizeof report); + int rc = session_compact_if_needed(s, true, &report, err); + if (rc > 0) { + s->rubric_summaries++; + session_print_compact(&report); + } + return rc > 0 ? 1 : 0; +} + +/* ------------------------------------------------------------------ */ +/* Goal */ +/* ------------------------------------------------------------------ */ + +int session_evaluate_goal(session *s, const goal_spec *spec, + const char *last_reply, goal_verdict *out, + char **err) { + if (!s->goal_evaluator) { + if (err) + *err = xstrdup("no goal evaluator configured"); + return -1; + } + const char *reply = trimmed_nonempty(last_reply) + ? last_reply + : "(the agent finished this turn without a text " + "reply \xE2\x80\x94 it made only tool calls)"; + char *prompt = xstrfmt( + "GOAL (the success condition):\n%s\n\nAGENT'S LATEST REPLY:\n%s", + spec->criteria, reply); + sess_error se = {0}; + sess_error_clear(&se); + char *verdict_text = s->goal_evaluator->complete(s->goal_evaluator, prompt, + &se); + free(prompt); + if (!verdict_text) { + char *msg = xstrfmt("asking the goal evaluator: %s", + se.message ? se.message : "unknown"); + sess_error_clear(&se); + if (err) + *err = msg; + else + free(msg); + return -1; + } + goal_parse_verdict(verdict_text, out); + free(verdict_text); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Model / profile */ +/* ------------------------------------------------------------------ */ + +static int session_apply_preset(session *s, const char *name, char **err) { + const model_preset *p = + (s->dot.has_models) ? models_file_get(&s->dot.models, name) : NULL; + if (!p) { + char *e = xstrfmt("preset `%s` not found", name); + if (err) + *err = e; + else + free(e); + return -1; + } + if (sess_apply_overrides(&s->cfg, p->model, p->provider, p->base_url, + p->api_key, true, err) != 0) + return -1; + if (p->has_max_turns) + s->cfg.max_turns = p->max_turns; + if (p->has_context_limit) + s->cfg.context_limit = p->context_limit; + compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, + s->cfg.clear_threshold, &s->compact); + s->last_context_tokens = 0; + if (s->cfg.provider != PROV_OLLAMA && !s->cfg.base_url && + !s->cfg.api_key) { + char *e = xstrfmt("no API key for provider `%s` from preset `%s`", + provider_name(s->cfg.provider), name); + if (err) + *err = e; + else + free(e); + return -1; + } + free(s->active_preset); + s->active_preset = xstrdup(name); + session_active_reset(s); + return 0; +} + +int session_set_model(session *s, const char *name, char **err) { + if (s->dot.has_models && models_file_get(&s->dot.models, name) != NULL) + return session_apply_preset(s, name, err); + free(s->active_preset); + s->active_preset = NULL; + free(s->cfg.model); + s->cfg.model = xstrdup(name); + s->last_context_tokens = 0; + session_active_reset(s); + return 0; +} + +int session_set_profile(session *s, const char *id, char **err) { + if (id) { + if (!dotagents_enabled_sub_agent(&s->dot, id)) { + xbuf b; + xbuf_init(&b); + bool first = true; + for (size_t i = 0; i < s->dot.sub_agent_count; i++) { + if (!first) + xbuf_put(&b, ", "); + xbuf_put(&b, s->dot.sub_agents[i].id); + first = false; + } + char *known = s->dot.sub_agent_count ? xbuf_steal(&b) + : xstrdup("none defined"); + xbuf_free(&b); + char *e = xstrfmt("no enabled sub-agent `%s` (known: %s)", id, + known); + free(known); + if (err) + *err = e; + else + free(e); + return -1; + } + } + free(s->profile); + s->profile = id ? xstrdup(id) : NULL; + session_active_reset(s); + return 0; +} + +int session_refresh_dot(session *s, const char *workspace, const char *global, + char **err) { + (void)err; + dotagents fresh; + dotagents_load_from(&fresh, workspace, global); + char *bp = sess_base_preamble_for(&s->cfg, &fresh); + dotagents_free(&s->dot); + s->dot = fresh; + free(s->base_preamble); + s->base_preamble = bp; + session_active_reset(s); + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Save / load / resume */ +/* ------------------------------------------------------------------ */ + +void session_snapshot_free(session_snapshot *snap) { + if (!snap) + return; + free(snap->cwd); + config_free(&snap->cfg); + history_free(&snap->hist); + free(snap->active_preset); + free(snap->profile); + free(snap); +} + +static unsigned long long jnum(const nlohmann::json &j, const char *key) { + if (j.contains(key) && j.at(key).is_number()) + return (unsigned long long) j.at(key).get(); + return 0; +} + +static nlohmann::json snapshot_to_json(const session_snapshot *snap) { + nlohmann::json cfg = nlohmann::json::object(); + cfg["model"] = snap->cfg.model ? snap->cfg.model : ""; + cfg["provider"] = provider_name(snap->cfg.provider); + if (snap->cfg.base_url) + cfg["base_url"] = snap->cfg.base_url; + if (snap->cfg.api_key) + cfg["api_key"] = snap->cfg.api_key; + cfg["max_turns"] = snap->cfg.max_turns; + cfg["max_retries"] = snap->cfg.max_retries; + cfg["context_limit"] = snap->cfg.context_limit; + cfg["compact_threshold"] = snap->cfg.compact_threshold; + cfg["clear_threshold"] = snap->cfg.clear_threshold; + cfg["verbose"] = snap->cfg.verbose; + cfg["skill_index_cap"] = snap->cfg.skill_index_cap; + cfg["confirm_destructive"] = snap->cfg.confirm_destructive; + cfg["shell_allowlist"] = nlohmann::json::array(); + for (size_t i = 0; i < snap->cfg.shell_allowlist.len; i++) + cfg["shell_allowlist"].push_back(snap->cfg.shell_allowlist.items[i]); + cfg["shell_denylist"] = nlohmann::json::array(); + for (size_t i = 0; i < snap->cfg.shell_denylist.len; i++) + cfg["shell_denylist"].push_back(snap->cfg.shell_denylist.items[i]); + nlohmann::json hist = nlohmann::json::array(); + for (const auto &m : snap->hist.msgs) + hist.push_back(m); + nlohmann::json o; + o["version"] = snap->version; + o["cwd"] = snap->cwd ? snap->cwd : ""; + o["saved_at"] = snap->saved_at; + o["config"] = cfg; + o["history"] = std::move(hist); + if (snap->active_preset) + o["active_preset"] = snap->active_preset; + if (snap->profile) + o["profile"] = snap->profile; + o["last_context_tokens"] = snap->last_context_tokens; + return o; +} + +static session_snapshot *json_to_snapshot(const nlohmann::json &o) { + session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); + new (snap) session_snapshot{}; + snap->version = (unsigned long) jnum(o, "version"); + sl_init(&snap->cfg.shell_allowlist); + sl_init(&snap->cfg.shell_denylist); + { + std::string cwd = jstring(o, "cwd"); + snap->cwd = xstrdup(cwd.c_str()); + } + snap->saved_at = (unsigned long long) jnum(o, "saved_at"); + /* config */ + if (o.contains("config") && o.at("config").is_object()) { + const nlohmann::json &cfg = o.at("config"); + snap->cfg.model = xstrdup(jstring(cfg, "model").c_str()); + std::string provider = jstring(cfg, "provider"); + char *perr = NULL; + snap->cfg.provider = provider.empty() + ? PROV_OPENAI + : provider_parse(provider.c_str(), &perr); + free(perr); + std::string base_url = jstring(cfg, "base_url"); + if (!base_url.empty()) + snap->cfg.base_url = xstrdup(base_url.c_str()); + std::string api_key = jstring(cfg, "api_key"); + if (!api_key.empty()) + snap->cfg.api_key = xstrdup(api_key.c_str()); + snap->cfg.max_turns = (unsigned long) jnum(cfg, "max_turns"); + snap->cfg.max_retries = (unsigned long) jnum(cfg, "max_retries"); + snap->cfg.context_limit = (unsigned long) jnum(cfg, "context_limit"); + if (cfg.contains("compact_threshold") && + cfg.at("compact_threshold").is_number()) + snap->cfg.compact_threshold = + cfg.at("compact_threshold").get(); + if (cfg.contains("clear_threshold") && + cfg.at("clear_threshold").is_number()) + snap->cfg.clear_threshold = cfg.at("clear_threshold").get(); + if (cfg.contains("verbose") && cfg.at("verbose").is_boolean()) + snap->cfg.verbose = cfg.at("verbose").get(); + snap->cfg.skill_index_cap = + (unsigned long) jnum(cfg, "skill_index_cap"); + if (cfg.contains("confirm_destructive") && + cfg.at("confirm_destructive").is_boolean()) + snap->cfg.confirm_destructive = + cfg.at("confirm_destructive").get(); + if (cfg.contains("shell_allowlist") && cfg.at("shell_allowlist").is_array()) + for (const auto &item : cfg.at("shell_allowlist")) + if (item.is_string()) + sl_push_copy(&snap->cfg.shell_allowlist, + item.get_ref().c_str()); + if (cfg.contains("shell_denylist") && cfg.at("shell_denylist").is_array()) + for (const auto &item : cfg.at("shell_denylist")) + if (item.is_string()) + sl_push_copy(&snap->cfg.shell_denylist, + item.get_ref().c_str()); + } + /* history */ + if (o.contains("history") && o.at("history").is_array()) + for (const auto &m : o.at("history")) + if (m.is_object()) + snap->hist.msgs.push_back(m.get()); + std::string ap = jstring(o, "active_preset"); + if (!ap.empty()) + snap->active_preset = xstrdup(ap.c_str()); + std::string prof = jstring(o, "profile"); + if (!prof.empty()) + snap->profile = xstrdup(prof.c_str()); + snap->last_context_tokens = (unsigned long) jnum(o, "last_context_tokens"); + return snap; +} + +session_snapshot *session_snapshot_of(session *s) { + session_snapshot *snap = (session_snapshot *) xmalloc(sizeof *snap); + new (snap) session_snapshot{}; + snap->version = SNAPSHOT_VERSION; + char buf[4096]; + snap->cwd = getcwd(buf, sizeof buf) ? xstrdup(buf) : xstrdup(""); + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + snap->saved_at = (unsigned long long)ts.tv_sec; + snap->cfg = config_clone(&s->cfg); + for (const auto &m : s->hist.msgs) + snap->hist.msgs.push_back(m); + snap->active_preset = + s->active_preset ? xstrdup(s->active_preset) : NULL; + snap->profile = s->profile ? xstrdup(s->profile) : NULL; + snap->last_context_tokens = s->last_context_tokens; + return snap; +} + +/* Write `raw` to `path` atomically (same-dir temp file + rename). */ +static int write_atomic(const char *path, const char *raw, size_t len, + char **err) { + /* ensure parent dir(s) exist */ + const char *slash = strrchr(path, '/'); + if (slash && slash != path) { + char *parent = xstrndup(path, (size_t)(slash - path)); + if (mkdir_p(parent) != 0) { + free(parent); + if (err) + *err = xstrfmt("creating parent dirs for `%s`: %s", path, + strerror(errno)); + return -1; + } + free(parent); + } + char *tmp = xstrfmt("%s.XXXXXX", path); + int fd = mkstemp(tmp); + int rc = -1; + if (fd < 0) { + if (err) + *err = xstrfmt("creating temporary session file for `%s`: %s", + path, strerror(errno)); + } else if (fchmod(fd, 0600) != 0) { + if (err) + *err = xstrfmt("setting session file permissions: %s", strerror(errno)); + close(fd); + unlink(tmp); + } else { + size_t off = 0; + while (off < len) { + ssize_t n = write(fd, raw + off, len - off); + if (n > 0) + off += (size_t)n; + else if (n < 0 && errno == EINTR) + continue; + else + break; + } + if (off == len && fsync(fd) == 0 && close(fd) == 0) { + rc = rename(tmp, path); + if (rc != 0 && err) + *err = xstrfmt("renaming session file to `%s`: %s", path, + strerror(errno)); + } else { + int e = errno ? errno : EIO; + close(fd); + errno = e; + if (err) + *err = xstrfmt("writing session file: %s", strerror(errno)); + } + if (rc != 0) + unlink(tmp); + } + free(tmp); + return rc; +} + +void session_set_debug_path(session *s, char *path) { + if (!s) + return; + free(s->debug_path); + s->debug_path = path; /* may be NULL (disabled) */ +} + +int session_debug_dump(session *s, char **err) { + if (!s) + return 0; + if (!s->debug_path || s->debug_path[0] == '\0') + return 0; + nlohmann::json hist = nlohmann::json::array(); + for (const auto &m : s->hist.msgs) + hist.push_back(m); + std::string raw; + try { + raw = hist.dump(2); + } catch (...) { + if (err) + *err = xstrdup("encoding debug dump"); + return -1; + } + return write_atomic(s->debug_path, raw.c_str(), raw.size(), err); +} + +int session_save(session *s, const char *path, char **err) { + session_snapshot *snap = session_snapshot_of(s); + std::string raw; + try { + raw = snapshot_to_json(snap).dump(); + } catch (...) { + session_snapshot_free(snap); + if (err) + *err = xstrdup("encoding session"); + return -1; + } + session_snapshot_free(snap); + return write_atomic(path, raw.c_str(), raw.size(), err); +} + +session_snapshot *session_load(const char *path, char **err) { + char *raw = NULL; + size_t len = 0; + char *rerr = NULL; + if (read_file_to_buf(path, &raw, &len, &rerr) != 0) { + free(rerr); + if (raw) + free(raw); + char *e = xstrfmt("reading %s", path); + if (err) + *err = e; + else + free(e); + return NULL; + } + free(rerr); + nlohmann::json json; + try { + json = nlohmann::json::parse(raw); + } catch (...) { + free(raw); + char *e = xstrfmt("parsing %s", path); + if (err) + *err = e; + else + free(e); + return NULL; + } + free(raw); + long long version = -1; + if (json.is_object() && json.contains("version") && + json.at("version").is_number()) + version = (long long) json.at("version").get(); + if (version != (long long)SNAPSHOT_VERSION) { + char *e = xstrfmt("unsupported session version %lld (this build " + "understands %lu)", + version, SNAPSHOT_VERSION); + if (err) + *err = e; + else + free(e); + return NULL; + } + session_snapshot *snap = json_to_snapshot(json); + return snap; +} + +session *session_resume(session_snapshot *snap, dotagents dot, + llsh_model *model, char **err) { + (void)err; + char *profile = NULL; + if (snap->profile) { + if (dotagents_enabled_sub_agent(&dot, snap->profile)) { + profile = xstrdup(snap->profile); + } else { + fprintf(stderr, "warning: saved sub-agent `%s` is not available " + "here; using the main agent\n", + snap->profile); + } + } + session *s = (session *) xmalloc(sizeof *s); + new (s) session{}; + s->model = model; + s->cfg = config_clone(&snap->cfg); + s->dot = dot; + s->hist.msgs = snap->hist.msgs; + s->base_preamble = sess_base_preamble_for(&s->cfg, &s->dot); + s->active_preset = + snap->active_preset ? xstrdup(snap->active_preset) : NULL; + s->profile = profile; + s->last_context_tokens = snap->last_context_tokens; + s->summarizer = NULL; + s->goal_evaluator = NULL; + s->goal = NULL; + compact_config_from(s->cfg.context_limit, s->cfg.compact_threshold, + s->cfg.clear_threshold, &s->compact); + session_snapshot_free(snap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* Turn */ +/* ------------------------------------------------------------------ */ + +static void session_append_streamed(session *s, const char *prompt, + char *assistant, message *new_messages, + size_t n_new) { + if (new_messages && n_new > 0) { + for (size_t i = 0; i < n_new; i++) + s->hist.msgs.push_back(std::move(new_messages[i])); + delete[] new_messages; + } else { + history_push(&s->hist, msg_user_text(prompt)); + if (assistant && assistant[0]) + history_push(&s->hist, msg_assistant_text(assistant)); + } + free(assistant); +} + +static void session_discard_streamed(char *assistant, message *new_messages, + size_t n_new) { + if (new_messages) + delete[] new_messages; + free(assistant); +} + +static int session_run_turn_impl(session *s, const char *prompt, + std::atomic *stop, sess_emit_cb cb, + void *user, char **err, int *result) { + compact_report report; + memset(&report, 0, sizeof report); + char *cerr = NULL; + s->summary_attempted_this_turn = false; + int rc = session_compact_if_needed(s, false, &report, &cerr); + if (rc < 0) { + if (err) + *err = cerr; + else + free(cerr); + return -1; + } + if (rc == 1) + session_print_compact(&report); + + if (stop && stop->load()) { + history_push(&s->hist, msg_user_text(prompt)); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + + unsigned long max_retries = s->cfg.max_retries; + unsigned long retry = 0; + bool overflow_retried = false; + for (;;) { + sess_error e = {0}; + sess_error_clear(&e); + char *assistant = NULL; + unsigned long input_tokens = 0; + message *new_messages = NULL; + size_t n_new = 0; + int rcs = s->model->stream( + s->model, s->hist.msgs.data(), s->hist.msgs.size(), + prompt, cb, user, stop, &assistant, &input_tokens, + &new_messages, &n_new, &e); + if (rcs == SESS_STREAM_OK) { + session_append_streamed(s, prompt, assistant, new_messages, n_new); + if (input_tokens > 0) + s->last_context_tokens = input_tokens; + /* SelfCompact rubric probe: the turn is complete and the context + * size is final. A probe failure never fails the turn. */ + char *perr = NULL; + if (session_rubric_probe(s, &perr) != 0) { + fprintf(stderr, DIM "[rubric] %s" RESET "\n", + perr ? perr : "probe failed"); + free(perr); + } + if (result) + *result = SESS_TURN_COMPLETED; + return 0; + } + if (rcs == SESS_STREAM_STOPPED) { + session_append_streamed(s, prompt, assistant, new_messages, n_new); + sess_error_clear(&e); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + if (rcs == SESS_STREAM_RETRYABLE) { + session_discard_streamed(assistant, new_messages, n_new); + sess_retry *r = sess_classify_retry(&e); + if (retry < max_retries) { + retry++; + unsigned long delay = + (r && r->has_retry_after) ? r->retry_after_secs + : sess_backoff_secs(retry); + char *notice = xstrfmt( + "\n" DIM "[provider error (%s); retry %lu/%lu in %lus]" + RESET "\n", + r ? r->reason : "unknown", retry, max_retries, + sess_human_secs(delay)); + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = SESS_EMIT_VERBATIM; + ev.text = notice; + if (cb) + cb(&ev, user); + free(notice); + sess_retry_free(r); + sess_error_clear(&e); + /* Actually wait before retrying; a stop request ends the turn. */ + unsigned long delay_ms = delay * 1000UL; + for (unsigned long slept = 0; slept < delay_ms; slept += 50) { + if (stop && stop->load()) + break; + sleep_ms(50); + } + if (stop && stop->load()) { + history_push(&s->hist, msg_user_text(prompt)); + if (result) + *result = SESS_TURN_STOPPED; + return 0; + } + continue; + } else { + char *msg = xstrfmt("provider error after %lu retries: %s", + retry, + e.message ? e.message : "unknown"); + sess_error_clear(&e); + sess_retry_free(r); + if (err) + *err = msg; + else + free(msg); + return -1; + } + } + /* SESS_STREAM_FAILED */ + /* Context overflow is not a provider fault: the request grew past + * the window, so compact the history and retry once. One recovery + * per turn keeps a too-large tail (or a provider that reports a + * smaller window than it enforces) from looping the turn. */ + dbgf("RUN overflow-retried=%d is_overflow=%d limit=%lu\n",(int)overflow_retried,(int)sess_error_is_context_overflow(&e),s->compact.context_limit); + if (!overflow_retried && s->compact.context_limit > 0 && + sess_error_is_context_overflow(&e)) { + overflow_retried = true; + char *reason = e.message ? xstrdup(e.message) + : xstrdup("context overflow"); + sess_error_clear(&e); + sess_emit ev; + memset(&ev, 0, sizeof ev); + ev.kind = SESS_EMIT_VERBATIM; + ev.text = xstrfmt( + "\n" DIM + "[request exceeds the context window: %s; compacting and " + "retrying]" RESET "\n", + reason); + if (cb) + cb(&ev, user); + sess_emit_free(&ev); + free(reason); + compact_report crep; + memset(&crep, 0, sizeof crep); + char *cerr2 = NULL; + int crr = session_compact_if_needed(s, true, &crep, &cerr2); + if (cerr2) { + free(cerr2); + crr = 0; + } + if (crr > 0) + session_print_compact(&crep); + /* Retry once even when the compact pass was a no-op: a provider + * that reports a smaller window than it enforces may still accept + * the resubmitted request. overflow_retried stops a second pass. + * e was cleared above; the fresh error (if any) is surfaced below. */ + continue; + } + char *msg = xstrfmt("stream error: %s", + e.message ? e.message : "unknown"); + sess_error_clear(&e); + if (err) + *err = msg; + else + free(msg); + return -1; + } +} + +int session_run_turn(session *s, const char *prompt, std::atomic *stop, + sess_emit_cb cb, void *user, char **err, int *result) { + int rc = session_run_turn_impl(s, prompt, stop, cb, user, err, result); + /* --debug: refresh the history dump on every turn, whatever the outcome. + * A failed dump never fails the turn; it is reported on stderr. */ + char *derr = NULL; + if (session_debug_dump(s, &derr) != 0) { + if (s && s->debug_path) + fprintf(stderr, "debug: %s\n", derr ? derr : "dump failed"); + free(derr); + } + return rc; +} diff --git a/src/session.h b/src/session.h new file mode 100644 index 0000000..ce4dedf --- /dev/null +++ b/src/session.h @@ -0,0 +1,293 @@ +#ifndef LLSH_SESSION_H +#define LLSH_SESSION_H + +#include +#include + +#include "config.h" +#include "message.h" +#include "compact.h" +#include "goal.h" + +/* ------------------------------------------------------------------ */ +/* Model abstraction (mirrors the rig `Agent` a session drives). */ +/* A model is an opaque object with a small vtable: a single-shot */ +/* `complete` (summarizer / goal evaluator) and a `stream` that drains */ +/* one turn, emitting events as they arrive. */ +/* ------------------------------------------------------------------ */ + +/* One unit the stream can emit, in arrival order. */ +enum { + SESS_EMIT_TEXT = 0, + SESS_EMIT_REASONING, + SESS_EMIT_VERBATIM, + SESS_EMIT_STATUS, + SESS_EMIT_RESULT, +}; + +typedef struct sess_emit { + int kind; /* one of SESS_EMIT_* */ + char *text; /* TEXT/REASONING/VERBATIM/RESULT: body */ + bool from_shell; /* RESULT: came from a shell-style tool */ + unsigned long input_tokens; /* STATUS: the new context size */ +} sess_emit; + +void sess_emit_free(sess_emit *e); + +/* Callback invoked for each emit. Return 0 to continue, non-zero to abort + * (a user stop). */ +typedef int (*sess_emit_cb)(const sess_emit *e, void *user); + +/* Error reported by a model call. `message` is owned by the receiver and + * must be freed; the other fields are plain values. */ +typedef struct sess_error { + char *message; + int http_status; /* 0 = no HTTP status */ + int transport; /* 1 = bare transport error (no status) */ + unsigned long retry_after_secs; /* 0 = none */ +} sess_error; + +void sess_error_clear(sess_error *e); + +/* Terminal result of a model `stream` call. */ +enum { + SESS_STREAM_OK = 0, + SESS_STREAM_RETRYABLE, /* transient, safe to retry (no output committed) */ + SESS_STREAM_FAILED, /* non-retryable failure */ + SESS_STREAM_STOPPED, /* the user stopped the turn */ +}; + +typedef struct llsh_model llsh_model; +struct llsh_model { + /* Single-shot completion (summarizer / goal evaluator). Returns the + * model's text (caller frees) or NULL on error (`err` filled). */ + char *(*complete)(const llsh_model *m, const char *prompt, sess_error *err); + + /* Stream one turn: send history + prompt, call `cb` for each emit. + * `stop` is polled between emits; if set (or `cb` returns non-zero) the + * stream stops and returns SESS_STREAM_STOPPED. + * On SESS_STREAM_OK / SESS_STREAM_STOPPED: + * - *assistant_text (caller frees; may be empty) is the turn's prose; + * - *input_tokens (last completion call's input tokens, or 0) is set; + * - if the model produced structured history, *new_messages is an + * allocated array of owned `message` values and *n_new is its length; + * the caller takes ownership (release with `delete[]`). + * When no structured history is produced, leave *new_messages NULL and + * *n_new 0; the caller falls back to `assistant_text`. + * On SESS_STREAM_RETRYABLE / SESS_STREAM_FAILED fills *err. */ + int (*stream)(const llsh_model *m, + const message *history, size_t nhistory, + const char *prompt, + sess_emit_cb cb, void *user, + std::atomic *stop, + char **assistant_text, unsigned long *input_tokens, + message **new_messages, size_t *n_new, + sess_error *err); + + /* Optional destructor. When set, `llsh_model_free` calls it; otherwise + * it falls back to plain `free`. */ + void (*destroy)(llsh_model *m); +}; + +static inline void llsh_model_free(llsh_model *m) { + if (!m) + return; + if (m->destroy) + m->destroy(m); + else + free(m); +} + +/* ------------------------------------------------------------------ */ +/* Pure helpers (no session state; directly unit-testable). */ +/* ------------------------------------------------------------------ */ + +/* HTTP statuses treated as transient (worth retrying). */ +bool sess_is_transient_status(int status); + +/* Exponential backoff for the `attempt`-th retry (1-indexed), in whole + * seconds: 1, 2, 4, 8, 16, then capped at 30. */ +unsigned long sess_backoff_secs(unsigned long attempt); + +/* Render a duration (whole seconds, at least 1) for a retry notice. */ +unsigned long sess_human_secs(unsigned long secs); + +/* Classification of a retryable provider failure. */ +typedef struct sess_retry { + char *reason; /* owned */ + unsigned long retry_after_secs; /* 0 = none */ + bool has_retry_after; +} sess_retry; +void sess_retry_free(sess_retry *r); + +/* Classify a model error as retryable. Returns a freshly allocated + * sess_retry, or NULL if the error is permanent. Caller frees. */ +sess_retry *sess_classify_retry(const sess_error *e); + +/* True when a model error means the request exceeded the provider's context + * window (a 400-class error mentioning context length, or a 413). The turn + * can recover by compacting history and retrying once. */ +bool sess_error_is_context_overflow(const sess_error *e); + +/* Map one streamed assistant content item to an emit. + * `kind`: "text" | "reasoning" | "tool_call". + * `text`: the body (text/reasoning) or, for a tool call, the tool name. + * `args`: for a tool call, the args JSON; else NULL. + * Returns 0 and fills *out (caller frees the fields), or 1 for no emit. */ +int sess_assistant_emit(const char *kind, const char *text, const char *args, + sess_emit *out); + +/* Render a tool's result for display: the full result body, normalized to + * end with exactly one newline. Returns 0 and fills *out (caller frees the + * fields), or 1 for no emit (NULL / empty result). */ +int sess_tool_result_emit(const char *tool, const char *result, + sess_emit *out); + +/* The models.json `default` preset name active at startup, or NULL. + * Caller frees. */ +char *session_active_preset_from(const dotagents *dot); + +/* The base system preamble: `.agents` system-prompt.md (a full override) or + * the configured preamble, with the project sections appended. Caller frees. */ +char *sess_base_preamble_for(const config *cfg, const dotagents *dot); + +/* The full preamble for a profile: the sub-agent's agent.md body plus the + * project sections, or the base preamble when the profile is inactive or its + * sub-agent is missing. Caller frees. `profile` may be NULL. */ +char *sess_preamble_for_profile(const dotagents *dot, const config *cfg, + const char *profile); + +/* Apply model/connection overrides onto `cfg`. In strict mode (a preset) an + * unparseable provider is a hard error; otherwise it is skipped. Returns 0 on + * success, -1 on error (*err set). */ +int sess_apply_overrides(config *cfg, const char *model, const char *provider, + const char *base_url, const char *api_key, + bool strict, char **err); + +/* The effective config for a profile: a deep copy of `cfg` with the + * profile's config.json overrides applied. Caller frees with config_free. + * `profile` may be NULL (returns a plain clone). */ +config sess_effective_for_profile(const config *cfg, const dotagents *dot, + const char *profile); + +/* ------------------------------------------------------------------ */ +/* Session */ +/* ------------------------------------------------------------------ */ + +typedef struct session session; + +/* Turn result. */ +enum { SESS_TURN_COMPLETED = 0, SESS_TURN_STOPPED = 1 }; + +/* Adopt `cfg`, `dot`, and `model` (the caller no longer owns them). */ +session *session_new_with_model(llsh_model *model, config cfg, dotagents dot); +void session_free(session *s); + +/* Inject a tool-less summarizer / goal evaluator / rubric judge (adopted). + * Returns s. */ +session *session_with_summarizer(session *s, llsh_model *summarizer); +session *session_with_goal_evaluator(session *s, llsh_model *evaluator); +session *session_with_rubric(session *s, llsh_model *rubric); + +/* -- accessors (caller frees returned strings) -- */ +char *session_model(session *s); +const config *session_config(const session *s); +char *session_active_preset(session *s); +char *session_profile_name(session *s); +const dotagents *session_dot(const session *s); /* borrowed; do not free */ +/* Allowed built-in tools for the active profile (borrowed; do not free). + * An empty list means "all built-in tools allowed". */ +const strlist *session_allowed_tools(session *s); +unsigned long session_context_tokens(session *s); +unsigned long session_context_limit(session *s); +const goal_report *session_goal_report(session *s); +void session_set_goal_report(session *s, goal_report *r); /* adopts r */ +char *session_last_assistant_text(session *s); +/* Both return borrowed state, valid until the next set_model / set_profile / + * refresh_dot / apply_preset call. */ +const char *session_current_preamble(session *s); +const config *session_active_config(session *s); +int session_refresh_context_limit(session *s); /* 1 if a provider-reported limit was adopted, 0 if none was learned */ + +/* -- history -- */ +size_t session_history_len(session *s); +const message *session_history_at(session *s, size_t i); +void session_push_history(session *s, message m); /* copies m */ +unsigned long session_last_context_tokens(session *s); +void session_set_last_context_tokens(session *s, unsigned long t); +void session_clear_history(session *s); +/* Remove the most recent user turn. Returns -1 when none, else the number of + * messages removed. */ +int session_undo_last_turn(session *s); +size_t session_message_count(session *s); +char *session_transcript_markdown(session *s); + +/* -- compaction -- */ +/* Run the two-tier compaction pass if the context is large enough (or always, + * when `force`). Returns 0 = no pass (report untouched), 1 = pass ran + * (report filled), -1 = error (*err set). */ +int session_compact_if_needed(session *s, bool force, compact_report *report, + char **err); +void session_print_compact(const compact_report *report); + +/* -- rubric compaction (SelfCompact, arXiv:2606.23525) -- */ +/* Record one completed tool round. The provider calls this after each tool + * round; it feeds the rubric probe gates. */ +void session_note_tool_round(session *s); + +/* Model-invoked compaction (the `compact` tool): run the pass unconditionally. + * Returns 0 = no pass (history too short), 1 = pass ran (report filled), + * -1 = error (*err set). */ +int session_force_compact(session *s, compact_report *report, char **err); + +/* End-of-turn rubric probe: when the gates are open, ask the summarizer to + * judge the trajectory and compact only if the rubric fires. Returns + * 0 = no compaction, 1 = compacted, -1 = error (*err set). */ +int session_rubric_probe(session *s, char **err); + +/* -- goal -- */ +int session_evaluate_goal(session *s, const goal_spec *spec, + const char *last_reply, goal_verdict *out, + char **err); + +/* -- model / profile -- */ +int session_set_model(session *s, const char *name, char **err); +int session_set_profile(session *s, const char *id, char **err); +int session_refresh_dot(session *s, const char *workspace, const char *global, + char **err); + +/* -- debug dump -- */ +/* Set (or clear with NULL) the file the whole conversation history is + * written to when `--debug` is active. `path` is adopted. */ +void session_set_debug_path(session *s, char *path); +/* Write the current history to the debug path (no-op when unset). + * Returns 0 on success / no-op, -1 on error (*err set, caller frees). */ +int session_debug_dump(session *s, char **err); + +/* -- save / load / resume -- */ +typedef struct session_snapshot { + unsigned long version; + char *cwd; + unsigned long long saved_at; + config cfg; /* owned (name avoids the C++ type/member clash) */ + history hist; /* owned */ + char *active_preset; /* may be NULL */ + char *profile; /* may be NULL */ + unsigned long last_context_tokens; +} session_snapshot; + +void session_snapshot_free(session_snapshot *snap); +session_snapshot *session_snapshot_of(session *s); +int session_save(session *s, const char *path, char **err); +session_snapshot *session_load(const char *path, char **err); +/* Adopt `snap`, `dot`, and `model`; free the snapshot. */ +session *session_resume(session_snapshot *snap, dotagents dot, + llsh_model *model, char **err); + +/* Run one user turn. `stop` may be set by the caller/cb to abort; if already + * set the prompt is kept in context and the turn is Stopped. `err` and + * `result` are optional. Returns 0 on a completed/stopped turn, -1 on error. */ +int session_run_turn(session *s, const char *prompt, std::atomic *stop, + sess_emit_cb cb, void *user, char **err, int *result); + +#endif diff --git a/src/shell.cpp b/src/shell.cpp new file mode 100644 index 0000000..9b0bcae --- /dev/null +++ b/src/shell.cpp @@ -0,0 +1,303 @@ +#define _XOPEN_SOURCE 600 +#include "shell.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Fallback cap for in-memory PTY capture; matches the config + * `max_output_chars` default. The caller overrides with the effective value. */ +#define DEFAULT_SHELL_CAPTURE_CAP 16000UL + +/* Read the current terminal size (0x0 when not a TTY). */ +static struct winsize current_winsize(void) { + struct winsize ws = {0, 0, 0, 0}; + (void)ioctl(STDIN_FILENO, TIOCGWINSZ, &ws); + return ws; +} + +/* Reader thread: stream the shell's PTY output to stdout, collapsing CRLF to + * a single LF (the real terminal's ONLCR will re-expand it exactly once). */ +static void *shell_reader(void *arg) { + interactive_shell *s = (interactive_shell *) arg; + unsigned char buf[8192]; + unsigned char out[16384]; + size_t out_len = 0; + bool pending_cr = false; + + for (;;) { + ssize_t n = read(s->reader_fd, buf, sizeof buf); + if (n <= 0) + break; + for (ssize_t i = 0; i < n; i++) { + unsigned char b = buf[i]; + if (pending_cr) { + if (b == '\n') { + out[out_len++] = '\n'; + } else { + if (out_len + 2 > sizeof out) { + write(STDOUT_FILENO, out, out_len); + out_len = 0; + } + out[out_len++] = '\r'; + out[out_len++] = b; + } + pending_cr = false; + } else if (b == '\r') { + pending_cr = true; + } else { + out[out_len++] = b; + } + } + if (out_len) { + (void)write(STDOUT_FILENO, out, out_len); + pthread_mutex_lock(&s->out_mu); + if (s->outbuf.len < s->capture_cap) + xbuf_putn(&s->outbuf, (const char *)out, out_len); + pthread_mutex_unlock(&s->out_mu); + out_len = 0; + } + } + if (pending_cr) + out[out_len++] = '\r'; + if (out_len) { + (void)write(STDOUT_FILENO, out, out_len); + pthread_mutex_lock(&s->out_mu); + if (s->outbuf.len < s->capture_cap) + xbuf_putn(&s->outbuf, (const char *)out, out_len); + pthread_mutex_unlock(&s->out_mu); + } + pthread_mutex_lock(&s->done_mu); + s->reader_done = true; + pthread_mutex_unlock(&s->done_mu); + return NULL; +} + +interactive_shell *shell_spawn(void) { + const char *shell = getenv("SHELL"); + if (!shell || !*shell) + shell = "bash"; + + char *argv[] = {(char *)shell, "-i", NULL}; + struct winsize ws = current_winsize(); + + int master = 0; + pid_t pid = forkpty(&master, NULL, NULL, &ws); + if (pid < 0) + return NULL; + if (pid == 0) { + /* Child: reset inherited signal handlers, then exec. */ + signal(SIGINT, SIG_DFL); + signal(SIGQUIT, SIG_DFL); + signal(SIGTERM, SIG_DFL); + execvp(shell, argv); + _exit(127); + } + + interactive_shell *s = (interactive_shell *) xmalloc(sizeof *s); + memset(s, 0, sizeof *s); + s->pid = (int)pid; + s->master = master; + s->last_cwd = NULL; + + int reader_fd = dup(master); + if (reader_fd < 0) { + close(master); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s); + return NULL; + } + s->reader_fd = reader_fd; + s->reader_done = false; + xbuf_init(&s->outbuf); + pthread_mutex_init(&s->out_mu, NULL); + /* Cap the in-memory capture at the tool output limit (the config + * `max_output_chars` default); the caller overrides this with the + * effective config value. The transcript is trimmed to the same limit + * when recorded. */ + s->capture_cap = DEFAULT_SHELL_CAPTURE_CAP; + if (pthread_mutex_init(&s->done_mu, NULL) != 0) { + close(reader_fd); + close(master); + free(s); + return NULL; + } + if (pthread_create(&s->reader_th, NULL, shell_reader, s) != 0) { + (void)kill((pid_t)pid, SIGKILL); + (void)kill(-(pid_t)pid, SIGKILL); + int status = 0; + (void)waitpid((pid_t)pid, &status, 0); + close(reader_fd); + close(master); + pthread_mutex_destroy(&s->done_mu); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s); + return NULL; + } + return s; +} + +int shell_write(const interactive_shell *s, const void *buf, size_t len) { + if (s->master < 0) + return -1; + const unsigned char *p = (const unsigned char* ) buf; + size_t off = 0; + while (off < len) { + ssize_t n = write(s->master, p + off, len - off); + if (n < 0) { + if (errno == EINTR) + continue; + return -1; + } + off += (size_t)n; + } + return 0; +} + +void shell_update_winsize(const interactive_shell *s) { + if (s->master < 0) + return; + struct winsize ws = current_winsize(); + (void)ioctl(s->master, TIOCSWINSZ, &ws); +} + +/* Read a process's cwd from /proc//cwd (Linux). Freshly allocated, or + * NULL when the process is gone / unreadable. */ +static char *read_proc_cwd(int pid) { + if (pid <= 0) + return NULL; + char path[64]; + snprintf(path, sizeof path, "/proc/%d/cwd", pid); + char link[4096]; + ssize_t n = readlink(path, link, sizeof link - 1); + if (n <= 0) + return NULL; + link[n] = '\0'; + return xstrdup(link); +} + +char *shell_final_cwd(const interactive_shell *s) { + char *live = read_proc_cwd(s->pid); + if (live) + return live; + if (s->last_cwd) + return xstrdup(s->last_cwd); + return NULL; +} + +/* Strip terminal control/escape sequences from `src` so the text is suitable + * for the conversation context (no colors, cursor moves, OSC prompts, or + * prompt decorations). Keeps printable text, tabs, and newlines; a lone CR + * (not part of the already-collapsed CRLF) becomes a newline. */ +static char *shell_sanitize(const char *src) { + xbuf b; + xbuf_init(&b); + const unsigned char *p = (const unsigned char *)src; + while (*p) { + if (*p == '\x1B') { + if (p[1] == '[') { + /* CSI: params + intermediates, terminated by 0x40-0x7E. */ + const unsigned char *q = p + 2; + while (*q && *q < 0x40) q++; + if (*q) q++; + p = q; + } else if (p[1] == ']') { + /* OSC: terminated by BEL (0x07) or ST (ESC \). */ + const unsigned char *q = p + 2; + while (*q && *q != '\x07' && + !(*q == '\x1B' && q[1] == '\\')) + q++; + if (*q == '\x07') + q++; + else if (*q == '\x1B') + q += 2; + p = q; + } else { + /* Other two/three-byte escapes (e.g. ESC ( B, ESC 7). */ + p += (p[1] != 0) ? 2 : 1; + } + continue; + } + if (*p == '\r') { + xbuf_push(&b, '\n'); + p++; + continue; + } + if (*p < 0x20 && *p != '\t' && *p != '\n') { + p++; + continue; + } + xbuf_push(&b, (char)*p); + p++; + } + return xbuf_steal(&b); +} + +char *shell_captured(interactive_shell *s) { + pthread_mutex_lock(&s->out_mu); + const char *raw = s->outbuf.data ? s->outbuf.data : ""; + char *out = shell_sanitize(raw); + xbuf_reset(&s->outbuf); + pthread_mutex_unlock(&s->out_mu); + return out; +} + +bool shell_reap_if_dead(interactive_shell *s) { + if (s->pid == 0) + return true; + /* Wait for the PTY reader to see EOF before returning to the chat prompt, + * so final shell output is not interleaved with it. */ + pthread_mutex_lock(&s->done_mu); + bool reader_done = s->reader_done; + pthread_mutex_unlock(&s->done_mu); + if (!reader_done) { + /* Track where the shell is while it is still alive: once it has + * exited, /proc//cwd is unreadable even before the reap. */ + char *cwd = read_proc_cwd(s->pid); + if (cwd) { + free(s->last_cwd); + s->last_cwd = cwd; + } + return false; + } + int status = 0; + pid_t r = waitpid((pid_t)s->pid, &status, WNOHANG); + if (r == (pid_t)s->pid) { + s->pid = 0; + return true; + } + return false; +} + +void shell_free(interactive_shell *s) { + if (!s) + return; + if (s->pid != 0) { + /* Kill the shell (and its process group) so it does not print a + * newline or prompt fragment while we redraw the chat prompt. */ + (void)kill((pid_t)s->pid, SIGKILL); + (void)kill(-(pid_t)s->pid, SIGKILL); + int status = 0; + (void)waitpid((pid_t)s->pid, &status, 0); + s->pid = 0; + } + if (s->master >= 0) { + close(s->master); + s->master = -1; + } + (void)pthread_join(s->reader_th, NULL); + pthread_mutex_destroy(&s->done_mu); + xbuf_free(&s->outbuf); + pthread_mutex_destroy(&s->out_mu); + free(s->last_cwd); + s->last_cwd = NULL; + free(s); +} diff --git a/src/shell.h b/src/shell.h new file mode 100644 index 0000000..18c8f67 --- /dev/null +++ b/src/shell.h @@ -0,0 +1,49 @@ +#ifndef LLSH_SHELL_H +#define LLSH_SHELL_H + +#include "util.h" +#include +#include + +/* An interactive user shell running on a private PTY. llsh forwards raw + * terminal input to the PTY and streams the PTY's output back to the real + * terminal, so the shell's own line editor (Tab completion, history, …) is + * used while in shell mode. Mirrors the Rust `InteractiveShell` in main.rs. + * POSIX-only (forkpty/raw mode). */ +typedef struct interactive_shell { + int pid; /* shell PID; 0 once reaped */ + int master; /* PTY master fd; -1 once closed */ + int reader_fd; /* dup of master, owned by the reader thread */ + pthread_t reader_th; + pthread_mutex_t done_mu; /* guards reader_done */ + bool reader_done; /* set by the reader thread on EOF */ + xbuf outbuf; /* owned; output capture (shared with reader thread) */ + pthread_mutex_t out_mu; /* guards outbuf */ + unsigned long capture_cap; /* max bytes to capture (bounds memory) */ + char *last_cwd; /* owned; last observed cwd (fallback after reap) */ +} interactive_shell; + +/* Fork `$SHELL` (else `bash`) with `-i` on a private PTY and start a reader + * thread that streams the shell's output to stdout (CRLF collapsed to LF). + * Returns NULL on failure. */ +interactive_shell *shell_spawn(void); +/* Forward raw input bytes to the shell. 0 on success, -1 on error. */ +int shell_write(const interactive_shell *s, const void *buf, size_t len); +/* Resize the PTY to match the current terminal size. */ +void shell_update_winsize(const interactive_shell *s); +/* True once the shell has exited, its output is drained, and it has been + * reaped. Cheap to call each loop iteration while in shell mode. */ +bool shell_reap_if_dead(interactive_shell *s); +/* The directory the shell ended in: a live `/proc//cwd` read while it + * is still alive, falling back to the last observed directory once reaped. + * Returns a freshly allocated string, or NULL when unknown. */ +char *shell_final_cwd(const interactive_shell *s); +/* Everything the shell has printed so far (owned copy), and reset the + * capture. Thread-safe against the reader thread; returns an empty string + * when nothing was captured. */ +char *shell_captured(interactive_shell *s); +/* Kill the shell (whole process group), close the PTY, and join the reader + * thread. Safe to call on a fully-reaped shell. */ +void shell_free(interactive_shell *s); + +#endif diff --git a/src/tools.cpp b/src/tools.cpp new file mode 100644 index 0000000..7656b23 --- /dev/null +++ b/src/tools.cpp @@ -0,0 +1,1341 @@ +#include "tools.h" +#include "escape.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "limits.h" +#include "llm.h" +#include "verbose.h" + +/* Cap on bytes retained per output stream (mirrors tools.rs). */ +#define MAX_SHELL_OUTPUT_BYTES 1000000u +#define MAX_MEMORY_HITS 10u +#define MAX_MEMORY_BODY_CHARS 2000u +#define MAX_TOOL_HTTP_BYTES (4u * 1024u * 1024u) + +typedef struct { + xbuf *buf; + size_t cap; +} tool_http_write_ctx; + +static size_t tool_http_write(char *data, size_t size, size_t nmemb, void *ud) { + tool_http_write_ctx *ctx = (tool_http_write_ctx *)ud; + if (size && nmemb > SIZE_MAX / size) + return 0; + size_t n = size * nmemb; + if (ctx->buf->len > ctx->cap || n > ctx->cap - ctx->buf->len) + return 0; + xbuf_putn(ctx->buf, data, n); + return n; +} + +/* ------------------------------------------------------------------ */ +/* Small helpers */ +/* ------------------------------------------------------------------ */ + +/* ASCII lowercase copy (Rust to_lowercase for ASCII input). */ +static char *lower_dup(const char *s) { + xbuf b; + xbuf_init(&b); + for (const unsigned char *p = (const unsigned char *)s; *p; p++) + xbuf_push(&b, (char)((*p >= 'A' && *p <= 'Z') ? *p + 32 : *p)); + return xbuf_steal(&b); +} + +/* ------------------------------------------------------------------ */ +/* read_file */ +/* ------------------------------------------------------------------ */ + +/* Total number of lines in `data` (a single trailing newline does not count + * as an extra line; an empty buffer is 0). */ +static unsigned long count_lines(const char *data, size_t len) { + if (len == 0) + return 0; + unsigned long nl = 0; + for (size_t i = 0; i < len; i++) + if (data[i] == '\n') + nl++; + return (data[len - 1] == '\n') ? nl : nl + 1; +} + +/* Newly-allocated copy of the line range [start_line, start_line + max_lines) + * of `data`. start_line is 1-indexed (values < 1 mean "from the start"); + * max_lines 0 means "no limit". A start_line past the last line yields "". */ +static char *slice_lines(const char *data, size_t len, unsigned long start_line, + unsigned long max_lines) { + const char *end = data + len; + const char *p = data; + unsigned long line = 1; + while (line < start_line) { + const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p)); + if (!nl) { + p = end; + break; /* start_line is past the last line */ + } + p = nl + 1; + line++; + } + const char *sel = p; + if (max_lines > 0) { + unsigned long taken = 0; + while (taken < max_lines && p < end) { + const char *nl = (const char* ) memchr(p, '\n', (size_t)(end - p)); + if (!nl) { + p = end; + break; + } + p = nl + 1; + taken++; + } + } + size_t out_len = (size_t)(p - sel); + char *out = (char *) xmalloc(out_len + 1); + memcpy(out, sel, out_len); + out[out_len] = '\0'; + return out; +} + +char *tool_read_file(const char *path, unsigned long offset, unsigned long limit, + char **err) { + if (!path || !path[0]) { + if (err) + *err = xstrdup("missing `path` argument (expected a non-empty " + "string)"); + return NULL; + } + verbose_log("tool_call", + (const vlog_kv[]){VLOG_STR("name", "read_file"), + VLOG_STR("path", path)}, + 2); + const tool_limits *lim = tool_limits_current(); + char *data = NULL; + size_t len = 0; + struct stat st; + if (stat(path, &st) == 0 && S_ISDIR(st.st_mode)) { + if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): " + "Is a directory (os error 21)", path); + return NULL; + } + size_t max_bytes = lim->max_file_bytes > SIZE_MAX + ? SIZE_MAX + : (size_t)lim->max_file_bytes; + char *read_err = NULL; + if (read_file_to_buf_limited(path, max_bytes, &data, &len, &read_err) != 0) { + if (err && read_err && xstreq(read_err, "file exceeds configured size limit")) + *err = xstrfmt("`%s` is too large to read (limit %llu bytes)", + path, lim->max_file_bytes); + else if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): %s", + path, read_err ? read_err : strerror(errno)); + free(read_err); + return NULL; + } + free(read_err); + if (memchr(data, '\0', len) || !utf8_is_valid(data, len)) { + free(data); + if (err) + *err = xstrfmt("failed to read `%s` as text (binary or unreadable?): " + "stream did not contain valid UTF-8", + path); + return NULL; + } + /* Apply offset/limit: return only the requested line range. offset is a + * 1-indexed start line (0/1 = from the top); limit is a max line count + * (0 = all). Note how many lines remain when a limit truncated the file. */ + if (offset > 0 || limit > 0) { + unsigned long start = (offset < 1) ? 1 : offset; + unsigned long total = count_lines(data, len); /* lines in the whole file */ + char *sl = slice_lines(data, len, start, limit); + free(data); + if (limit > 0) { + unsigned long last_shown = start + limit - 1; + if (last_shown > total) + last_shown = total; + if (total > last_shown) { + unsigned long remaining = total - last_shown; + char *note = xstrfmt("%s\n[... %lu more %s; use offset=%lu to " + "continue ...]", + sl, remaining, + remaining == 1 ? "line" : "lines", + last_shown + 1); + free(sl); + sl = note; + } + } + data = sl; + } + verbose_log("tool_result", + (const vlog_kv[]){VLOG_STR("name", "read_file"), + VLOG_BOOL("ok", true)}, + 2); + return data; +} + +/* ------------------------------------------------------------------ */ +/* write_file */ +/* ------------------------------------------------------------------ */ + +char *tool_write_file(const char *path, const char *content, char **err) { + if (!path || !path[0] || !content) { + if (err) + *err = xstrdup("write_file requires non-empty `path` and string `content`"); + return NULL; + } + const char *slash = strrchr(path, '/'); + if (slash && slash != path) { + char *parent = xstrndup(path, (size_t)(slash - path)); + if (mkdir_p(parent) != 0) { + if (err) + *err = xstrfmt("creating parent dirs for `%s`: %s", path, + strerror(errno)); + free(parent); + return NULL; + } + free(parent); + } + size_t clen = strlen(content); + if (write_file_from_buf(path, content, clen, NULL) != 0) { + if (err) + *err = xstrfmt("failed to write `%s`: %s", path, strerror(errno)); + return NULL; + } + return xstrfmt("wrote %zu bytes to %s", clen, path); +} + +/* ------------------------------------------------------------------ */ +/* run_shell */ +/* ------------------------------------------------------------------ */ + +/* Lossy UTF-8: copy valid code points verbatim and replace each invalid + * byte with one U+FFFD, so the result is always well-formed UTF-8. */ +static char *utf8_lossy(const char *s, size_t len) { + xbuf b; + xbuf_init(&b); + const char *p = s; + const char *end = s + len; + while (p < end) { + int n = 0; + unsigned char c = (unsigned char)*p; + if (c < 0x80) + n = 1; + else if ((c & 0xE0) == 0xC0 && c >= 0xC2 && end - p >= 2 && (p[1] & 0xC0) == 0x80) + n = 2; + else if ((c & 0xF0) == 0xE0 && end - p >= 3 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && !(c == 0xE0 && (unsigned char)p[1] < 0xA0) && + !(c == 0xED && (unsigned char)p[1] >= 0xA0)) + n = 3; + else if ((c & 0xF8) == 0xF0 && end - p >= 4 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80 && + c <= 0xF4 && !(c == 0xF0 && (unsigned char)p[1] < 0x90) && + !(c == 0xF4 && (unsigned char)p[1] >= 0x90)) + n = 4; + if (n > 0) { + xbuf_putn(&b, p, (size_t)n); + p += (size_t)n; + } else { + xbuf_put(&b, "\xEF\xBF\xBD"); + p++; + } + } + return xbuf_steal(&b); +} + +static bool drain_fd(int fd, xbuf *b, size_t cap, bool *eof) { + char tmp[8192]; + for (;;) { + /* Always keep draining so a child writing more than `cap` does not + * block on a full pipe; bytes past the cap are read and discarded. */ + ssize_t r = read(fd, tmp, sizeof tmp); + if (r > 0) { + if (b->len < cap) { + size_t take = (size_t)r; + if (take > cap - b->len) + take = cap - b->len; + xbuf_putn(b, tmp, take); + } + continue; + } + if (r == 0) { + *eof = true; + } else if (errno == EAGAIN || errno == EWOULDBLOCK) { + *eof = false; + } else { + *eof = true; /* treat I/O errors as end-of-stream */ + } + return *eof; + } +} + +static void set_nonblock(int fd) { + int fl = fcntl(fd, F_GETFL, 0); + if (fl >= 0) + fcntl(fd, F_SETFL, fl | O_NONBLOCK); +} + +char *tool_run_shell_timed(const char *command, unsigned long timeout_secs, + char **err) { + const char *label = command; + int out_fd[2] = {-1, -1}; + int err_fd[2] = {-1, -1}; + if (pipe(out_fd) != 0 || pipe(err_fd) != 0) { + close(out_fd[0]); + close(out_fd[1]); + close(err_fd[0]); + close(err_fd[1]); + if (err) + *err = xstrfmt("failed to run `%s`: %s (os error %d)", label, + strerror(errno), errno); + return NULL; + } + pid_t pid = fork(); + if (pid < 0) { + close(out_fd[0]); + close(out_fd[1]); + close(err_fd[0]); + close(err_fd[1]); + if (err) + *err = xstrfmt("failed to run `%s`: %s (os error %d)", label, + strerror(errno), errno); + return NULL; + } + if (pid == 0) { + /* Own process group so a timeout kills the whole tree. */ + if (setpgid(0, 0) != 0) + _exit(125); + if (dup2(out_fd[1], 1) < 0) + _exit(126); + if (dup2(err_fd[1], 2) < 0) + _exit(126); + int dn = open("/dev/null", O_RDONLY); + if (dn >= 0) { + if (dup2(dn, 0) < 0) + _exit(126); + close(dn); + } + char *argv[] = {"sh", "-c", (char *)command, NULL}; + execvp("sh", argv); + _exit(127); + } + close(out_fd[1]); + close(err_fd[1]); + int out_r = out_fd[0]; + int err_r = err_fd[0]; + set_nonblock(out_r); + set_nonblock(err_r); + xbuf ob; + xbuf eb; + xbuf_init(&ob); + xbuf_init(&eb); + bool out_eof = false; + bool err_eof = false; + int status = 0; + bool done = false; + bool timed_out = false; + double deadline = now_secs() + (double)timeout_secs; + while (!done) { + pid_t r = waitpid(pid, &status, WNOHANG); + if (r == pid) { + done = true; + break; + } + if (r < 0 && errno == ECHILD) { + done = true; + break; + } + if (now_secs() >= deadline) { + kill(-pid, SIGKILL); + for (;;) { + r = waitpid(pid, &status, WNOHANG); + if (r == pid) + break; + sleep_ms(20); + } + timed_out = true; + break; + } + if (!out_eof) + drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof); + if (!err_eof) + drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof); + sleep_ms(20); + } + if (!timed_out) { + /* Bounded final drain: pick up buffered data after the child exits + * without spinning forever if a grandchild keeps the pipe open. */ + double drain_deadline = now_secs() + 2.0; + while (!out_eof && now_secs() < drain_deadline) { + drain_fd(out_r, &ob, MAX_SHELL_OUTPUT_BYTES, &out_eof); + if (!out_eof) + sleep_ms(5); + } + while (!err_eof && now_secs() < drain_deadline) { + drain_fd(err_r, &eb, MAX_SHELL_OUTPUT_BYTES, &err_eof); + if (!err_eof) + sleep_ms(5); + } + } + close(out_r); + close(err_r); + char *stdout_s = utf8_lossy(ob.data ? ob.data : "", ob.len); + char *stderr_s = utf8_lossy(eb.data ? eb.data : "", eb.len); + xbuf_free(&ob); + xbuf_free(&eb); + if (timed_out) { + free(stdout_s); + free(stderr_s); + if (err) + *err = xstrfmt("command timed out after %lus: %s", + timeout_secs, label); + return NULL; + } + char *so = xstr_rtrim_dup(stdout_s); + char *se = xstr_rtrim_dup(stderr_s); + free(stdout_s); + free(stderr_s); + xbuf t; + xbuf_init(&t); + if (so[0]) + xbuf_put(&t, so); + if (se[0]) { + if (t.len) + xbuf_push(&t, '\n'); + xbuf_put(&t, "[stderr]\n"); + xbuf_put(&t, se); + } + if (t.len == 0) + xbuf_put(&t, "(no output)"); + if (WIFEXITED(status)) { + int code = WEXITSTATUS(status); + if (code != 0) + xbuf_fmt(&t, "\n[exit code %d]", code); + } else if (WIFSIGNALED(status)) { + xbuf_put(&t, "\n[terminated by signal]"); + } + char *text = xbuf_steal(&t); + char *out = tool_truncate_output(text); + free(text); + free(so); + free(se); + return out; +} + +char *tool_run_shell(const char *command, char **err) { + if (!command || !command[0]) { + if (err) + *err = xstrdup("run_shell requires a non-empty `command`"); + return NULL; + } + const tool_limits *lim = tool_limits_current(); + if (shell_gate(command, lim, err) != 0) + return NULL; + verbose_log("tool_call", + (const vlog_kv[]){VLOG_STR("name", "run_shell"), + VLOG_STR("command", command)}, + 2); + unsigned long timeout = lim->shell_timeout_secs; + char *out = tool_run_shell_timed(command, timeout, err); + verbose_log("tool_result", + (const vlog_kv[]){VLOG_STR("name", "run_shell"), + VLOG_BOOL("ok", out != NULL)}, + 2); + return out; +} + +char *tool_truncate_output(const char *text) { + const tool_limits *lim = tool_limits_current(); + unsigned long limit = lim->max_output_chars > 0 ? lim->max_output_chars : 1; + size_t len = strlen(text); + if (len <= limit) + return xstrdup(text); + size_t cut = utf8_byte_len(text, limit); + size_t max_printf_precision = (size_t)(~0u >> 1); + if (cut > max_printf_precision) + cut = max_printf_precision; + char *out = xstrfmt("%.*s", (int)cut, text); + char *res = xstrfmt("%s\u2026\n[output truncated at %lu chars]", out, + limit); + free(out); + return res; +} + +/* ------------------------------------------------------------------ */ +/* use_skill */ +/* ------------------------------------------------------------------ */ + +char *tool_use_skill(const dotagents *dot, const char *id, char **err) { + char *out = NULL; + const skill *sk = dot ? dotagents_find_skill(dot, id) : NULL; + if (sk) { + if (sk->enabled) { + char *body = xstr_rtrim_dup(sk->body ? sk->body : ""); + out = xstrfmt("%s\n\n%s", sk->name, body); + free(body); + } else { + if (err) + *err = xstrfmt("skill `%s` is disabled (enabled: false)", id); + } + } else { + if (!dot || dot->skill_count == 0) { + if (err) + *err = xstrdup( + "no skills defined (expected .agents/skills//skill.md)"); + } else { + xbuf ids; + xbuf_init(&ids); + for (size_t i = 0; i < dot->skill_count; i++) { + if (i) + xbuf_put(&ids, ", "); + xbuf_put(&ids, dot->skills[i].id); + } + if (err) + *err = xstrfmt("no skill `%s`; available: %s", id, + ids.data ? ids.data : ""); + xbuf_free(&ids); + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* search_memories */ +/* ------------------------------------------------------------------ */ + +void tool_tokenize(const char *query, strlist *out) { + char *low = lower_dup(query); + size_t n = strlen(low); + size_t start = 0; + for (size_t i = 0; i <= n; i++) { + char c = i < n ? low[i] : '\0'; + bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (alnum) { + continue; + } + if (i > start) + sl_push(out, xstrndup(low + start, i - start)); + start = i + 1; + } + free(low); +} + +bool tool_is_subsequence(const char *needle, const char *haystack) { + const char *h = haystack; + for (const char *n = needle; *n; n++) { + const char *found = NULL; + while (*h) { + if (*h == *n) { + found = h; + break; + } + h++; + } + if (!found) + return false; + h = found + 1; + } + return true; +} + +double tool_field_match_strength(const char *field, const char *term) { + if (!field[0] || !term[0]) + return 0.0; + if (strcmp(field, term) == 0) + return 1.0; + size_t start = 0; + for (size_t i = 0;; i++) { + char c = field[i]; + bool alnum = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); + if (alnum) + continue; + if (i > start) { + char *tok = xstrndup(field + start, i - start); + bool eq = xstreq(tok, term); + free(tok); + if (eq) + return 0.9; + } + start = i + 1; + if (!c) + break; + } + if (strstr(field, term)) + return 0.7; + if (strlen(term) >= 3 && tool_is_subsequence(term, field)) + return 0.4; + return 0.0; +} + +double tool_memory_relevance(const memory *m, const strlist *terms) { + if (terms->len == 0) + return 0.0; + char *title = lower_dup(m->title ? m->title : ""); + xbuf tagsb; + xbuf_init(&tagsb); + for (size_t i = 0; i < m->tag_count; i++) { + if (i) + xbuf_put(&tagsb, " "); + xbuf_put(&tagsb, m->tags[i]); + } + char *tags_raw = xbuf_steal(&tagsb); + char *tags = lower_dup(tags_raw); + free(tags_raw); + char *summary = lower_dup(m->summary ? m->summary : ""); + char *body = lower_dup(m->body ? m->body : ""); + const char *fields[4] = {title, tags, summary, body}; + const double weights[4] = {4.0, 3.0, 2.0, 1.0}; + double points = 0.0; + size_t matched = 0; + for (size_t t = 0; t < terms->len; t++) { + double best = 0.0; + for (int f = 0; f < 4; f++) { + double s = weights[f] * tool_field_match_strength(fields[f], + terms->items[t]); + if (s > best) + best = s; + } + if (best > 0.0) { + matched++; + points += best; + } + } + free(title); + free(tags); + free(summary); + free(body); + if (matched == 0) + return 0.0; + double coverage = (double)matched / (double)terms->len; + return points * (1.0 + coverage); +} + +typedef struct { + double score; + const memory *m; +} scored; + +static int cmp_scored(const void *a, const void *b) { + double sa = ((const scored *)a)->score; + double sb = ((const scored *)b)->score; + if (sa != sb) + return sa < sb ? 1 : -1; + int ra = memory_importance_rank(((const scored *)a)->m); + int rb = memory_importance_rank(((const scored *)b)->m); + if (ra != rb) + return ra - rb; + return strcmp(((const scored *)a)->m->id, ((const scored *)b)->m->id); +} + +char *tool_search_memories(const dotagents *dot, const char *query, + char **err) { + char *needle = xstr_trim_dup(query); + if (!needle[0]) { + if (err) + *err = xstrdup("query must not be empty"); + free(needle); + return NULL; + } + char *out = NULL; + if (!dot || dot->memory_count == 0) { + out = xstrdup("no memories defined (expected .agents/memories/*.md)"); + free(needle); + return out; + } + { + strlist terms; + sl_init(&terms); + tool_tokenize(needle, &terms); + scored *arr = (scored *) xmalloc(dot->memory_count * sizeof(scored)); + size_t n = 0; + for (size_t i = 0; i < dot->memory_count; i++) { + double s = tool_memory_relevance(&dot->memories[i], &terms); + if (s > 0.0) + arr[n++] = (scored){s, &dot->memories[i]}; + } + sl_free(&terms); + if (n == 0) { + out = xstrfmt("no memories matching `%s`", needle); + } else { + qsort(arr, n, sizeof(scored), cmp_scored); + xbuf b; + xbuf_init(&b); + size_t shown = n < MAX_MEMORY_HITS ? n : MAX_MEMORY_HITS; + for (size_t i = 0; i < shown; i++) { + const memory *m = arr[i].m; + xbuf_fmt(&b, "## %s [importance: %s]\n", m->title, + m->importance ? m->importance : ""); + if (m->tag_count > 0) { + xbuf tags; + xbuf_init(&tags); + for (size_t j = 0; j < m->tag_count; j++) { + if (j) + xbuf_put(&tags, ", "); + xbuf_put(&tags, m->tags[j]); + } + xbuf_fmt(&b, "tags: %s\n", tags.data ? tags.data : ""); + xbuf_free(&tags); + } + if (m->summary && m->summary[0]) + xbuf_fmt(&b, "%s (summary)\n", m->summary); + char *body = xstr_trim_dup(m->body ? m->body : ""); + if (body[0]) { + size_t blen = strlen(body); + size_t cut = utf8_byte_len(body, MAX_MEMORY_BODY_CHARS); + xbuf_putn(&b, body, cut); + if (cut < blen) + xbuf_put(&b, "\xE2\x80\xA6"); + xbuf_put(&b, "\n"); + } + free(body); + xbuf_put(&b, "\n"); + } + if (n > MAX_MEMORY_HITS) + xbuf_fmt(&b, "\xE2\x80\xA6" + "and %zu more matches\n", + n - MAX_MEMORY_HITS); + out = xbuf_steal(&b); + } + free(arr); + free(needle); + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* Embedding tool */ +/* ------------------------------------------------------------------ */ + +void embed_target_free(embed_target *t) { + free(t->model); + free(t->base_url); + free(t->api_key); + t->model = t->base_url = t->api_key = NULL; +} + +int tool_embed_resolve(const config *cfg, const dotagents *dot, + embed_target *out, char **err) { + memset(out, 0, sizeof *out); + if (err) + *err = NULL; + if (!dot || !dot->has_models) { + if (err) + *err = xstrdup("embeddings not configured: add a preset to " + ".agents/models.json"); + return -1; + } + const char *name = dot->models.default_name; + if (!name || !*name) { + if (err) + *err = xstrdup("embeddings not configured: no `default` preset " + "in .agents/models.json"); + return -1; + } + const model_preset *p = + (dot && dot->has_models) ? models_file_get(&dot->models, name) : NULL; + if (!p) { + if (err) + *err = xstrfmt("embeddings preset `%s` not found in " + ".agents/models.json", + name); + return -1; + } + out->model = (p->model && p->model[0]) ? xstrdup(p->model) + : (cfg->model ? xstrdup(cfg->model) + : NULL); + out->base_url = (p->base_url && p->base_url[0]) + ? xstrdup(p->base_url) + : (cfg->base_url ? xstrdup(cfg->base_url) : NULL); + out->api_key = (p->api_key && p->api_key[0]) + ? xstrdup(p->api_key) + : (cfg->api_key ? xstrdup(cfg->api_key) : NULL); + if (!out->model || !out->model[0] || !out->api_key || !out->api_key[0]) { + char *m = xstrfmt("embeddings preset `%s` needs a `model` and an " + "`api_key`", + name); + embed_target_free(out); + if (err) + *err = m; + else + free(m); + return -1; + } + return 0; +} + +char *tool_embed_request(const char *model, const char *text, char **err) { + if (err) + *err = NULL; + if (!model || !model[0]) { + if (err) + *err = xstrdup("embeddings: empty model name"); + return NULL; + } + if (!text || !text[0]) { + if (err) + *err = xstrdup("embed: empty text"); + return NULL; + } + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "model", model); + cJSON_AddStringToObject(o, "input", text); + char *s = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + return s; +} + +char *tool_embed_url(const char *base_url) { + const char *base = + base_url && base_url[0] ? base_url : "https://api.openai.com/v1"; + size_t n = strlen(base); + while (n && base[n - 1] == '/') + n--; + xbuf b; + xbuf_init(&b); + xbuf_putn(&b, base, n); + xbuf_put(&b, "/embeddings"); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +int tool_embed_parse(const char *body, char **vector_json, char **err) { + if (vector_json) + *vector_json = NULL; + if (err) + *err = NULL; + if (!body || !body[0]) { + if (err) + *err = xstrdup("embeddings: empty response body"); + return -1; + } + cJSON *o = cJSON_Parse(body); + if (!o) { + if (err) + *err = xstrdup("embeddings: invalid JSON in response"); + return -1; + } + const cJSON *data = cJSON_GetObjectItemCaseSensitive(o, "data"); + const cJSON *first = + (data && cJSON_IsArray(data) && cJSON_GetArraySize(data) > 0) + ? cJSON_GetArrayItem(data, 0) + : NULL; + const cJSON *emb = first + ? cJSON_GetObjectItemCaseSensitive(first, "embedding") + : NULL; + if (!emb || !cJSON_IsArray(emb) || cJSON_GetArraySize(emb) == 0) { + if (err) + *err = xstrdup("embeddings: no `data[0].embedding` array in " + "response"); + cJSON_Delete(o); + return -1; + } + const cJSON *v = NULL; + cJSON_ArrayForEach(v, emb) { + if (!cJSON_IsNumber(v) || !isfinite(v->valuedouble)) { + if (err) + *err = xstrdup("embeddings: non-numeric element in vector"); + cJSON_Delete(o); + return -1; + } + } + cJSON *arr = cJSON_Duplicate(emb, 1); + cJSON_Delete(o); + if (!arr) { + if (err) + *err = xstrdup("embeddings: out of memory"); + return -1; + } + char *s = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + if (vector_json) + *vector_json = s; + else + free(s); + return s ? 0 : -1; +} + +/* One-shot JSON POST (mirrors agent_http_get). Returns 0 + *body owned + * (may be empty), or -1 with *err owned. */ +static int embed_post(const char *url, const char *api_key, const char *req, + long *status, char **body, char **err) { + if (body) + *body = NULL; + if (err) + *err = NULL; + CURL *c = curl_easy_init(); + if (!c) { + if (err) + *err = xstrdup("could not initialize curl"); + return -1; + } + xbuf b; + xbuf_init(&b); + struct curl_slist *h = NULL; + if (api_key && api_key[0]) { + xbuf hb; + xbuf_init(&hb); + xbuf_fmt(&hb, "Authorization: Bearer %s", api_key); + h = curl_slist_append(h, hb.data); + xbuf_free(&hb); + } + h = curl_slist_append(h, "Content-Type: application/json"); + curl_easy_setopt(c, CURLOPT_HTTPHEADER, h); + curl_easy_setopt(c, CURLOPT_URL, url); + curl_easy_setopt(c, CURLOPT_POST, 1L); + curl_easy_setopt(c, CURLOPT_POSTFIELDS, req); + curl_easy_setopt(c, CURLOPT_TIMEOUT, 30L); + curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L); + const char *ca = llsh_ca_bundle(); + if (ca) + curl_easy_setopt(c, CURLOPT_CAINFO, ca); + tool_http_write_ctx write_ctx = {&b, MAX_TOOL_HTTP_BYTES}; + curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, tool_http_write); + curl_easy_setopt(c, CURLOPT_WRITEDATA, &write_ctx); + CURLcode rc = curl_easy_perform(c); + curl_slist_free_all(h); + if (rc != CURLE_OK) { + char *e = xstrdup(curl_easy_strerror(rc)); + free(b.data); + curl_easy_cleanup(c); + if (err) + *err = e; + return -1; + } + long code = 0; + curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code); + if (status) + *status = code; + if (body) + *body = xbuf_steal(&b); + else + free(b.data); + curl_easy_cleanup(c); + return 0; +} + +char *tool_embed(const char *text, const config *cfg, const dotagents *dot, + char **err) { + if (err) + *err = NULL; + embed_target t; + if (tool_embed_resolve(cfg, dot, &t, err) != 0) + return NULL; + char *req = tool_embed_request(t.model, text, NULL); + char *url = tool_embed_url(t.base_url); + long status = 0; + char *body = NULL; + int rc = embed_post(url, t.api_key, req, &status, &body, err); + free(req); + free(url); + if (rc != 0) { + free(body); + return NULL; + } + char *vec = NULL; + if (status < 200 || status >= 300 || tool_embed_parse(body, &vec, err) != 0) { + if (!err || !*err) { + char *m = xstrfmt("embeddings request failed (HTTP %ld): %s", + status, body ? body : "(no body)"); + if (err) + *err = m; + else + free(m); + } + free(body); + embed_target_free(&t); + return NULL; + } + free(body); + cJSON *v = cJSON_Parse(vec); + int dims = v ? cJSON_GetArraySize(v) : 0; + xbuf b; + xbuf_init(&b); + xbuf_fmt(&b, "{\"dimensions\":%d,\"vector\":%s}", dims, vec); + char *out = xbuf_steal(&b); + xbuf_free(&b); + free(vec); + embed_target_free(&t); + if (v) + cJSON_Delete(v); + return out; +} + +/* ------------------------------------------------------------------ */ +/* llm (one-shot completion / embed) */ +/* ------------------------------------------------------------------ */ + +char *tool_llm(const llm_tool_call *call, const config *cfg, + const dotagents *dot, char **err) { + if (err) + *err = NULL; + if (!call || !call->prompt || !call->prompt[0]) { + if (err) + *err = xstrdup("llm: missing required `prompt`"); + return NULL; + } + if (call->embed) + return tool_embed(call->prompt, cfg, dot, err); + + if (call->response_schema && call->response_schema[0]) { + cJSON *s = cJSON_Parse(call->response_schema); + if (!s) { + if (err) + *err = xstrdup("llm: `response_schema` is not valid JSON"); + return NULL; + } + cJSON_Delete(s); + } + + llm_conn conn; + switch (cfg->provider) { + case PROV_ANTHROPIC: + conn.provider = LLM_ANTHROPIC; + break; + case PROV_OLLAMA: + conn.provider = LLM_OLLAMA; + break; + default: + conn.provider = LLM_OPENAI; + break; + } + conn.model = + (call->model && call->model[0]) ? call->model : cfg->model; + if (!conn.model || !conn.model[0]) { + if (err) + *err = xstrdup("llm: no model configured"); + return NULL; + } + conn.base_url = cfg->base_url; + conn.api_key = cfg->api_key; + conn.ca_bundle = llsh_ca_bundle(); + + llm_params p; + llm_params_init(&p); + p.temperature = call->temperature; + p.max_tokens = call->max_tokens; + p.reasoning_effort = call->reasoning_effort; + p.thinking_budget = call->thinking_budget; + p.response_schema = call->response_schema; + + llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = call->prompt; + llm_message one; + one.role = LLM_ROLE_USER; + one.parts = ∂ + one.part_count = 1; + + char *req = llm_build_request_ex(&conn, NULL, &one, 1, NULL, false, &p); + char *url = llm_url(&conn); + long status = 0; + char *body = NULL, *headers = NULL, *herr = NULL; + int hrc = llm_post(&conn, url, req, &status, &body, &headers, &herr, + NULL, NULL); + free(req); + free(url); + if (hrc != 0) { + if (err) + *err = herr ? herr : xstrdup("llm: transport error"); + free(body); + free(headers); + return NULL; + } + char *result = NULL; + llm_completion c; + memset(&c, 0, sizeof c); + if (llm_parse_body(&conn, body, &c) == 0) { + result = (c.text && c.text[0]) ? c.text : xstrdup(""); + c.text = NULL; + if (c.reasoning && c.reasoning[0]) { + char *full = xstrfmt("[reasoning]\n%s\n\n[answer]\n%s", + c.reasoning, result); + free(result); + result = full; + } + c.reasoning = NULL; + llm_completion_free(&c); + } + if (status < 200 || status >= 300 || !result) { + char *em = llm_parse_error(body ? body : "", status); + if (err) + *err = em; + free(result); + result = NULL; + } + free(body); + free(headers); + return result; +} + +/* ------------------------------------------------------------------ */ +/* Display vtable */ +/* ------------------------------------------------------------------ */ + +/* Owned copy of a string argument from an invocation's args JSON, or + * NULL when the JSON is missing / not an object / the key is absent or + * not a string. */ +static char *display_arg(const char *args_json, const char *key) { + if (!args_json || !args_json[0]) + return NULL; + cJSON *a = cJSON_Parse(args_json); + if (!a) + return NULL; + char *out = NULL; + const cJSON *v = cJSON_GetObjectItem(a, key); + if (cJSON_IsString(v) && v->valuestring) + out = xstrdup(v->valuestring); + cJSON_Delete(a); + return out; +} + +/* run_shell: present the invocation as a plain shell line, `$ ` + * (prompt in the accent color, the command muted like other tool args). + * Returns NULL (generic fallback) when no command string is present. */ +static char *display_run_shell(const char *args_json) { + char *cmd = display_arg(args_json, "command"); + if (!cmd || !cmd[0]) { + free(cmd); + return NULL; + } + char *line = xstrfmt("\n" ESC_COLOR_GREEN "$ " ESC_RESET ESC_DIM "%s" + ESC_RESET "\n", + cmd); + free(cmd); + return line; +} + +/* ------------------------------------------------------------------ */ +/* Registry -- the single source of truth for the built-in tools */ +/* ------------------------------------------------------------------ */ + +typedef struct { + const char *name; + const char *wire; /* description sent to the model (schema) */ + const char *display; /* one-liner for /tools */ + const char *preamble; /* one-liner for the default system preamble */ + tool_render_call render_call; /* transcript display hook (or NULL) */ + bool dim_result; /* result rendered muted (shell-style) */ +} tool_def; + +static const tool_def TOOL_DEFS[] = { + {"read_file", + "Read a file and return its text content. Optional `offset` (1-indexed " + "start line) and `limit` (max lines) read a line range; a trailing note " + "reports how many lines remain.", + "Read a file's contents, optionally a line range via `offset`/`limit`.", + "read a file's contents (optionally a line range)", + NULL, true}, + {"write_file", + "Write content to a path, creating parent directories as needed. " + "Overwrites the file if it exists.", + "Create or overwrite a file, creating parent dirs.", + "create or overwrite a file", + NULL, false}, + {"run_shell", + "Run a shell command via sh -c and return stdout/stderr and a " + "status note on failure. Use it for listing and searching too " + "(ls, rg, grep, find).", + "Run a shell command via sh -c (120s timeout, kills the process " + "tree).", + "run a shell command (`sh -c`) in the working directory", + display_run_shell, true}, + {"use_skill", + "Load the full instructions of a .agents skill by its id.", + "Load a .agents skill's full instructions by id.", + "load a .agents skill's full instructions by id", + NULL, false}, + {"search_memories", + "Search .agents memories by keyword or tag (ranked).", + "Search .agents memories (ranked, fuzzy) by keyword or tag.", + "search .agents memories by keyword or tag (ranked)", + NULL, false}, + {"embed", + "Embed a text into a vector via the configured embeddings endpoint. " + "Returns `{\"dimensions\":N,\"vector\":[...]}`. Use it for semantic " + "comparison: embed a query and candidates, then rank by cosine " + "similarity.", + "Embed text into a vector (configured `embedding` profile).", + "embed text into a vector for semantic comparison", + NULL, true}, + {"llm", + "One-shot LLM call: send `prompt` as a single user message to the " + "session's model and return the reply. Optional generation " + "parameters: `model` override, `temperature` (0..2), `max_tokens`, " + "`reasoning_effort` (low/medium/high), `thinking_budget` (tokens), " + "and `response_schema` (a JSON Schema the reply must match; the " + "model is instructed to answer with one conforming JSON object). " + "`embed: true` embeds the prompt instead and returns " + "`{\"dimensions\":N,\"vector\":[...]}`.", + "One-shot LLM call (JSON schema, reasoning params, or embed mode).", + "make a one-shot LLM call (JSON schema, reasoning params, embed mode)", + NULL, false}, + {"compact", + "Compress the conversation context now: summarize the earlier turns " + "into a compact summary and re-inject recent file reads. Use it when " + "the context is large and mostly settled, or before a long new task.", + "Summarize earlier context to free tokens (model-invoked compaction).", + "compact the conversation context (summarize the earlier turns)", + NULL, true}, +}; + +size_t tool_builtin_count(void) { + return sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]; +} + +const char *tool_builtin_name(size_t i) { + return i < tool_builtin_count() ? TOOL_DEFS[i].name : NULL; +} + +const tool_display *tool_display_for(const char *name) { + if (!name) + return NULL; + static tool_display table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]]; + static bool built = false; + if (!built) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + table[i].name = TOOL_DEFS[i].name; + table[i].render_call = TOOL_DEFS[i].render_call; + table[i].dim_result = TOOL_DEFS[i].dim_result; + } + built = true; + } + for (size_t i = 0; i < tool_builtin_count(); i++) + if (xstreq(table[i].name, name)) + return &table[i]; + return NULL; +} + +const char *tool_desc(const char *name) { + for (size_t i = 0; i < tool_builtin_count(); i++) + if (xstreq(TOOL_DEFS[i].name, name)) + return TOOL_DEFS[i].wire; + return ""; +} + +/* The JSON schema for a built-in tool's parameters (fresh object). */ +cJSON *tool_params_json(const char *name) { + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "type", "object"); + cJSON *properties = cJSON_AddObjectToObject(o, "properties"); + cJSON *required = cJSON_AddArrayToObject(o, "required"); + if (xstreq(name, "read_file")) { + cJSON *p = cJSON_AddObjectToObject(properties, "path"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", + "Path to read (relative to the working directory, or absolute)."); + cJSON *offset = cJSON_AddObjectToObject(properties, "offset"); + cJSON_AddStringToObject(offset, "type", "integer"); + cJSON_AddNumberToObject(offset, "minimum", 1); + cJSON_AddStringToObject(offset, "description", + "Optional 1-indexed line to start from (defaults to the first line)."); + cJSON *limit = cJSON_AddObjectToObject(properties, "limit"); + cJSON_AddStringToObject(limit, "type", "integer"); + cJSON_AddNumberToObject(limit, "minimum", 1); + cJSON_AddStringToObject(limit, "description", + "Optional maximum number of lines to read (defaults to the whole file)."); + cJSON_AddItemToArray(required, cJSON_CreateString("path")); + } else if (xstreq(name, "write_file")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "path"), + "type", "string"); + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "content"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("path")); + cJSON_AddItemToArray(required, cJSON_CreateString("content")); + } else if (xstreq(name, "run_shell")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "command"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("command")); + } else if (xstreq(name, "use_skill")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "id"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("id")); + } else if (xstreq(name, "search_memories")) { + cJSON_AddStringToObject(cJSON_AddObjectToObject(properties, "query"), + "type", "string"); + cJSON_AddItemToArray(required, cJSON_CreateString("query")); + } else if (xstreq(name, "embed")) { + cJSON *p = cJSON_AddObjectToObject(properties, "text"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", + "The text to embed (a sentence, paragraph, or document excerpt)."); + cJSON_AddItemToArray(required, cJSON_CreateString("text")); + } else if (xstreq(name, "llm")) { + cJSON *p = cJSON_AddObjectToObject(properties, "prompt"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", "The user message to send to the model in the one-shot call."); + p = cJSON_AddObjectToObject(properties, "model"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON_AddStringToObject(p, "description", "Optional model name override (defaults to the session model)."); + p = cJSON_AddObjectToObject(properties, "temperature"); + cJSON_AddStringToObject(p, "type", "number"); + cJSON_AddNumberToObject(p, "minimum", 0); + cJSON_AddNumberToObject(p, "maximum", 2); + cJSON_AddStringToObject(p, "description", "Optional sampling temperature (0..2)."); + p = cJSON_AddObjectToObject(properties, "max_tokens"); + cJSON_AddStringToObject(p, "type", "integer"); + cJSON_AddNumberToObject(p, "minimum", 1); + cJSON_AddStringToObject(p, "description", "Optional cap on generated tokens."); + p = cJSON_AddObjectToObject(properties, "reasoning_effort"); + cJSON_AddStringToObject(p, "type", "string"); + cJSON *effort = cJSON_CreateArray(); + cJSON_AddItemToArray(effort, cJSON_CreateString("low")); + cJSON_AddItemToArray(effort, cJSON_CreateString("medium")); + cJSON_AddItemToArray(effort, cJSON_CreateString("high")); + cJSON_AddItemToObject(p, "enum", effort); + cJSON_AddStringToObject(p, "description", "Optional reasoning effort: low, medium, or high."); + p = cJSON_AddObjectToObject(properties, "thinking_budget"); + cJSON_AddStringToObject(p, "type", "integer"); + cJSON_AddNumberToObject(p, "minimum", 1); + cJSON_AddStringToObject(p, "description", "Optional extended-thinking budget in tokens."); + p = cJSON_AddObjectToObject(properties, "response_schema"); + cJSON_AddStringToObject(p, "type", "object"); + cJSON_AddStringToObject(p, "description", "Optional JSON Schema the reply must match; the model answers with a single conforming JSON object."); + p = cJSON_AddObjectToObject(properties, "embed"); + cJSON_AddStringToObject(p, "type", "boolean"); + cJSON_AddStringToObject(p, "description", "Embed the prompt instead of calling the model (returns a vector)."); + cJSON_AddItemToArray(required, cJSON_CreateString("prompt")); + } + return o; +} + +/* The "Available tools:" section of the default system preamble. */ +char *tool_preamble_list(void) { + xbuf b; + xbuf_init(&b); + xbuf_put(&b, "Available tools:\n"); + for (size_t i = 0; i < tool_builtin_count(); i++) + xbuf_fmt(&b, "- %s: %s\n", TOOL_DEFS[i].name, TOOL_DEFS[i].preamble); + char *out = xbuf_steal(&b); + xbuf_free(&b); + return out; +} + +size_t tool_list_entries(const tool_entry **out) { + static tool_entry table[sizeof TOOL_DEFS / sizeof TOOL_DEFS[0]]; + static bool built = false; + if (!built) { + for (size_t i = 0; i < tool_builtin_count(); i++) { + table[i].name = TOOL_DEFS[i].name; + table[i].desc = TOOL_DEFS[i].display; + } + built = true; + } + *out = table; + return tool_builtin_count(); +} diff --git a/src/tools.h b/src/tools.h new file mode 100644 index 0000000..9423320 --- /dev/null +++ b/src/tools.h @@ -0,0 +1,118 @@ +#ifndef LLSH_TOOLS_H +#define LLSH_TOOLS_H + +#include + +#include "config.h" +#include "util.h" +#include "dotagents.h" + +/* Mirrors tools.rs: the built-in tools the agent can call. + * Each tool returns an owned string on success, or NULL with an owned + * error message in **err (caller frees whichever was returned). */ + +char *tool_read_file(const char *path, unsigned long offset, unsigned long limit, + char **err); +char *tool_write_file(const char *path, const char *content, char **err); +char *tool_run_shell(const char *command, char **err); +char *tool_run_shell_timed(const char *command, unsigned long timeout_secs, + char **err); +/* `dot` is the caller's loaded .agents snapshot (borrowed); NULL means "no + * skills/memories". */ +char *tool_use_skill(const dotagents *dot, const char *id, char **err); +char *tool_search_memories(const dotagents *dot, const char *query, char **err); + +/* Embedding tool. The `default` preset of `.agents/models.json` supplies + * the connection; preset fields fall back to the top-level values. */ +typedef struct { + char *model; /* owned */ + char *base_url; /* owned */ + char *api_key; /* owned */ +} embed_target; +void embed_target_free(embed_target *t); +/* Resolve the configured embedding connection. 0 + *out filled, or -1 with + * *err owned. */ +int tool_embed_resolve(const config *cfg, const dotagents *dot, + embed_target *out, char **err); + +/* Pure helpers (no network; unit-testable). */ +char *tool_embed_request(const char *model, const char *text, char **err); +char *tool_embed_url(const char *base_url); +/* `body` -> the `data[0].embedding` array as a JSON array string. 0 + + * *vector_json owned, or -1 with *err owned. */ +int tool_embed_parse(const char *body, char **vector_json, char **err); + +/* Full tool: resolve + POST {base}/embeddings + parse. Returns owned + * `{"dimensions":N,"vector":[...]}` or NULL with *err owned. */ +char *tool_embed(const char *text, const config *cfg, const dotagents *dot, + char **err); + +/* LLM call tool: a one-shot completion through the session's provider + * (OpenAI-compatible, Anthropic, or Ollama). `prompt` is the single user + * message. The remaining fields are optional generation parameters, each + * with an "unset" value (temperature < 0, max_tokens / thinking_budget 0, + * pointers NULL); `embed` true routes the prompt to the embeddings + * endpoint instead. All strings borrowed for the call's lifetime. */ +typedef struct { + const char *prompt; /* required */ + const char *model; /* optional override; NULL = session model */ + double temperature; /* < 0 = unset */ + unsigned long max_tokens; /* 0 = unset */ + const char *reasoning_effort; /* "low" / "medium" / "high"; NULL = unset */ + unsigned long thinking_budget; /* 0 = unset (tokens) */ + const char *response_schema; /* JSON Schema text; NULL = unset */ + bool embed; /* true = embed the prompt instead */ +} llm_tool_call; + +/* Full tool: one-shot completion (or embed when `call->embed`). Returns + * owned result text, or NULL with *err owned. */ +char *tool_llm(const llm_tool_call *call, const config *cfg, const dotagents *dot, + char **err); + +/* Pure helpers (mirroring the tested functions in tools.rs). */ +char *tool_truncate_output(const char *text); +void tool_tokenize(const char *query, strlist *out); +bool tool_is_subsequence(const char *needle, const char *haystack); +double tool_field_match_strength(const char *field, const char *term); +double tool_memory_relevance(const memory *m, const strlist *terms); + +/* Static registry for /tools display. Returns the entry count and sets + * *out to a static array valid for the process lifetime. */ +typedef struct { + const char *name; + const char *desc; +} tool_entry; +size_t tool_list_entries(const tool_entry **out); + +/* ------------------------------------------------------------------ */ +/* Display vtable: per-tool transcript rendering */ +/* ------------------------------------------------------------------ */ +/* + * Presentation hooks for the built-in tools. A tool may supply a + * `render_call` that renders the one-line transcript display for an + * invocation from its raw args JSON (freshly allocated; return NULL to + * fall back to the generic `# name ` line). Tools without an + * entry (e.g. MCP tools) always use the generic line. `dim_result` + * marks the tool's result as shell-style (the UI renders it muted). + */ +typedef char *(*tool_render_call)(const char *args_json); + +typedef struct { + const char *name; + tool_render_call render_call; /* NULL -> generic display */ + bool dim_result; +} tool_display; + +/* The vtable entry for a built-in tool (borrowed; valid for the + * process lifetime), or NULL when `name` is not a built-in tool. */ +const tool_display *tool_display_for(const char *name); + +/* Built-in tool registry (single source of truth; llm.h builds the + * provider tool schemas and the default preamble from it). */ +size_t tool_builtin_count(void); +const char *tool_builtin_name(size_t i); +const char *tool_desc(const char *name); /* schema description; "" if unknown */ +cJSON *tool_params_json(const char *name); /* fresh object; caller frees */ +char *tool_preamble_list(void); /* "Available tools:" section; caller frees */ + +#endif diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..4721e11 --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,628 @@ +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Allocation */ +/* ------------------------------------------------------------------ */ + +void *xmalloc(size_t n) { + void *p = malloc(n ? n : 1); + if (!p) { + fprintf(stderr, "llsh: out of memory\n"); + abort(); + } + return p; +} + +void *xrealloc(void *p, size_t n) { + void *q = realloc(p, n ? n : 1); + if (!q) { + fprintf(stderr, "llsh: out of memory\n"); + abort(); + } + return q; +} + +/* ------------------------------------------------------------------ */ +/* xbuf */ +/* ------------------------------------------------------------------ */ + +void xbuf_init(xbuf *b) { + b->data = NULL; + b->len = 0; + b->cap = 0; +} + +void xbuf_free(xbuf *b) { + free(b->data); + b->data = NULL; + b->len = b->cap = 0; +} + +void xbuf_reset(xbuf *b) { + b->len = 0; +} + +static void xbuf_reserve(xbuf *b, size_t extra) { + if (b->len + extra + 1 > b->cap) { + size_t cap = b->cap ? b->cap : 64; + while (cap < b->len + extra + 1) + cap *= 2; + b->data = (char *) xrealloc(b->data, cap); + b->cap = cap; + } +} + +void xbuf_putn(xbuf *b, const char *s, size_t n) { + xbuf_reserve(b, n); + memcpy(b->data + b->len, s, n); + b->len += n; + b->data[b->len] = '\0'; +} + +void xbuf_put(xbuf *b, const char *s) { + if (s) + xbuf_putn(b, s, strlen(s)); +} + +void xbuf_push(xbuf *b, char c) { + xbuf_reserve(b, 1); + b->data[b->len++] = c; + b->data[b->len] = '\0'; +} + +int xbuf_fmt(xbuf *b, const char *fmt, ...) { + va_list ap, ap2; + if (!fmt) + return 0; + va_start(ap, fmt); + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) { + va_end(ap); + return -1; + } + xbuf_reserve(b, (size_t)n); + vsnprintf(b->data + b->len, (size_t)n + 1, fmt, ap); + va_end(ap); + b->len += (size_t)n; + return n; +} + +char *xbuf_steal(xbuf *b) { + if (!b->data) { + b->data = (char *) xmalloc(1); + } + b->data[b->len] = '\0'; + char *s = b->data; + b->data = NULL; + b->len = b->cap = 0; + return s; +} + +size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud) { + xbuf_putn((xbuf *)ud, data, sz * nm); + return sz * nm; +} + +/* ------------------------------------------------------------------ */ +/* Strings */ +/* ------------------------------------------------------------------ */ + +char *xstrdup(const char *s) { + size_t n = s ? strlen(s) : 0; + char *p = (char *) xmalloc(n + 1); + if (s) + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +char *xstrndup(const char *s, size_t n) { + char *p = (char *) xmalloc(n + 1); + memcpy(p, s, n); + p[n] = '\0'; + return p; +} + +int xstreq(const char *a, const char *b) { + return a && b && strcmp(a, b) == 0; +} + +const char *xtrim_span(const char *s, size_t n, size_t *out_len) { + size_t a = 0, b = n; + while (a < b && isascii((unsigned char)s[a]) && isspace((unsigned char)s[a])) + a++; + while (b > a && isascii((unsigned char)s[b - 1]) && + isspace((unsigned char)s[b - 1])) + b--; + *out_len = b - a; + return s + a; +} + +bool utf8_is_ws(uint32_t c) { + return c == 0x09 || c == 0x0A || c == 0x0B || c == 0x0C || c == 0x0D || + c == 0x20 || c == 0x85 || c == 0xA0 || c == 0x1680 || + (c >= 0x2000 && c <= 0x200A) || c == 0x2028 || c == 0x2029 || + c == 0x202F || c == 0x205F || c == 0x3000; +} + +static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len); + +char *xstr_ltrim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + break; + p += l; + } + return xstrndup(p, end - p); +} + +char *xstr_rtrim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len, *last = s; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + last = p + l; + p += l; + } + return xstrndup(s, last - s); +} + +char *xstr_trim_dup(const char *s) { + if (!s) return xstrdup(""); + size_t len = strlen(s); + const char *p = s, *end = s + len; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + break; + p += l; + } + if (p == end) + return xstrdup(""); + const char *start = p; + const char *last = p; + while (p < end) { + size_t l; + uint32_t cp = decode_utf8(p, end, &l); + if (!utf8_is_ws(cp)) + last = p + l; + p += l; + } + return xstrndup(start, (size_t)(last - start)); +} + +char *xstrjoin2(const char *a, const char *b) { + xbuf b_; + xbuf_init(&b_); + xbuf_put(&b_, a); + xbuf_put(&b_, b); + return xbuf_steal(&b_); +} + +char *xvsnprintf(const char *fmt, va_list ap) { + va_list ap2; + va_copy(ap2, ap); + int n = vsnprintf(NULL, 0, fmt, ap2); + va_end(ap2); + if (n < 0) + return xstrdup(""); + char *p = (char *) xmalloc((size_t)n + 1); + vsnprintf(p, (size_t)n + 1, fmt, ap); + return p; +} + +char *xstrfmt(const char *fmt, ...) { + va_list ap; + va_start(ap, fmt); + char *s = xvsnprintf(fmt, ap); + va_end(ap); + return s; +} + +/* ------------------------------------------------------------------ */ +/* UTF-8 */ +/* ------------------------------------------------------------------ */ + +static uint32_t decode_utf8(const char *p, const char *end, size_t *out_len) { + unsigned char c = (unsigned char)p[0]; + if (c < 0x80) { + *out_len = 1; + return c; + } + if (end - p >= 2 && (c & 0xE0) == 0xC0 && (p[1] & 0xC0) == 0x80) { + *out_len = 2; + return ((uint32_t)(c & 0x1F) << 6) | (p[1] & 0x3F); + } + if (end - p >= 3 && (c & 0xF0) == 0xE0 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80) { + *out_len = 3; + return ((uint32_t)(c & 0x0F) << 12) | ((uint32_t)(p[1] & 0x3F) << 6) | + (p[2] & 0x3F); + } + if (end - p >= 4 && (c & 0xF8) == 0xF0 && (p[1] & 0xC0) == 0x80 && + (p[2] & 0xC0) == 0x80 && (p[3] & 0xC0) == 0x80) { + *out_len = 4; + return ((uint32_t)(c & 0x07) << 18) | + ((uint32_t)(p[1] & 0x3F) << 12) | + ((uint32_t)(p[2] & 0x3F) << 6) | (p[3] & 0x3F); + } + /* invalid: consume one byte */ + *out_len = 1; + return 0xFFFD; +} + +size_t utf8_len(const char *s) { + size_t n = 0; + const char *p = s, *end = s + strlen(s); + while (p < end) { + size_t l; + decode_utf8(p, end, &l); + p += l; + n++; + } + return n; +} + +size_t utf8_byte_len(const char *s, size_t max_points) { + size_t n = 0; + const char *p = s, *end = s + strlen(s); + while (p < end && n < max_points) { + size_t l; + decode_utf8(p, end, &l); + p += l; + n++; + } + return (size_t)(p - s); +} + +const char *utf8_next(const char *p, const char *end, size_t *cp) { + size_t l; + uint32_t c = decode_utf8(p, end, &l); + if (cp) + *cp = c; + return p + l; +} + +static int is_wide(uint32_t c) { + return (c >= 0x1100 && c <= 0x115F) || + (c >= 0x2E80 && c <= 0x303E) || + (c >= 0x3041 && c <= 0x33FF) || + (c >= 0x3400 && c <= 0x4DBF) || + (c >= 0x4E00 && c <= 0x9FFF) || + (c >= 0xA000 && c <= 0xA4CF) || + (c >= 0xAC00 && c <= 0xD7A3) || + (c >= 0xF900 && c <= 0xFAFF) || + (c >= 0xFE30 && c <= 0xFE4F) || + (c >= 0xFF00 && c <= 0xFF60) || + (c >= 0xFFE0 && c <= 0xFFE6) || + (c >= 0x1F300 && c <= 0x1F64F) || + (c >= 0x1F900 && c <= 0x1F9FF) || + (c >= 0x20000 && c <= 0x3FFFD); +} + +size_t utf8_display_width(const char *s) { + size_t w = 0; + const char *p = s, *end = s + strlen(s); + while (p < end) { + size_t l; + uint32_t c = decode_utf8(p, end, &l); + if (c >= 0x200B && c <= 0x200F) { /* zero-width joiners etc. */ + p += l; + continue; + } + if (c == 0) { + p += l; + continue; + } + w += is_wide(c) ? 2 : 1; + p += l; + } + return w; +} + +bool utf8_is_valid(const char *s, size_t len) { + const unsigned char *p = (const unsigned char *)s; + const unsigned char *end = p + len; + while (p < end) { + unsigned char c = *p; + int n; + if (c < 0x80) { + n = 1; + } else if ((c & 0xE0) == 0xC0) { + n = 2; + } else if ((c & 0xF0) == 0xE0) { + n = 3; + } else if ((c & 0xF8) == 0xF0) { + n = 4; + } else { + return false; + } + if ((size_t)(end - p) < (size_t)n) + return false; + for (int i = 1; i < n; i++) + if ((p[i] & 0xC0) != 0x80) + return false; + if ((n == 2 && c < 0xC2) || + (n == 3 && ((c == 0xE0 && p[1] < 0xA0) || + (c == 0xED && p[1] >= 0xA0))) || + (n == 4 && (c > 0xF4 || + (c == 0xF0 && p[1] < 0x90) || + (c == 0xF4 && p[1] >= 0x90)))) + return false; + p += (size_t)n; + } + return true; +} + +/* ------------------------------------------------------------------ */ +/* strlist */ +/* ------------------------------------------------------------------ */ + +void sl_init(strlist *l) { + l->items = NULL; + l->len = 0; + l->cap = 0; +} + +void sl_free(strlist *l) { + for (size_t i = 0; i < l->len; i++) + free(l->items[i]); + free(l->items); + sl_init(l); +} + +void sl_push(strlist *l, char *owned) { + if (l->len + 1 > l->cap) { + l->cap = l->cap ? l->cap * 2 : 8; + l->items = (char* * ) xrealloc(l->items, l->cap * sizeof(char *)); + } + l->items[l->len++] = owned; +} + +void sl_push_copy(strlist *l, const char *s) { + sl_push(l, xstrdup(s)); +} + +char *sl_pop_front(strlist *l) { + if (l->len == 0) + return NULL; + char *p = l->items[0]; + memmove(l->items, l->items + 1, (l->len - 1) * sizeof(*l->items)); + l->len--; + return p; +} + +/* ------------------------------------------------------------------ */ +/* Files */ +/* ------------------------------------------------------------------ */ + +int read_file_to_buf(const char *path, char **out, size_t *out_len, + char **err) { + FILE *f = fopen(path, "rb"); + if (!f) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + xbuf b; + xbuf_init(&b); + char chunk[8192]; + size_t r; + while ((r = fread(chunk, 1, sizeof chunk, f)) > 0) + xbuf_putn(&b, chunk, r); + if (ferror(f)) { + fclose(f); + xbuf_free(&b); + if (err) + *err = xstrdup("read error"); + return -1; + } + fclose(f); + size_t total = b.len; + if (out) + *out = xbuf_steal(&b); + if (out_len) + *out_len = total; + return 0; +} + +int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out, + size_t *out_len, char **err) { + if (out) + *out = NULL; + if (err) + *err = NULL; + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + struct stat st; + int stat_rc = fstat(fd, &st); + if (stat_rc != 0 || !S_ISREG(st.st_mode)) { + int e = stat_rc != 0 ? errno : EINVAL; + close(fd); + errno = e; + if (err) + *err = xstrdup(stat_rc != 0 ? strerror(e) : "not a regular file"); + return -1; + } + xbuf b; + xbuf_init(&b); + char chunk[8192]; + int rc = 0; + for (;;) { + size_t room = max_bytes - b.len; + size_t want = room < sizeof chunk ? room + 1 : sizeof chunk; + ssize_t n = read(fd, chunk, want); + if (n > 0) { + if ((size_t)n > room) { + if (err) + *err = xstrdup("file exceeds configured size limit"); + rc = -1; + break; + } + xbuf_putn(&b, chunk, (size_t)n); + } else if (n == 0) { + break; + } else if (errno != EINTR) { + if (err) + *err = xstrdup(strerror(errno)); + rc = -1; + break; + } + } + close(fd); + if (rc != 0) { + xbuf_free(&b); + return -1; + } + size_t total = b.len; + if (out) + *out = xbuf_steal(&b); + if (out_len) + *out_len = total; + xbuf_free(&b); + return 0; +} + +int write_file_from_buf(const char *path, const char *data, size_t len, + char **err) { + FILE *f = fopen(path, "wb"); + if (!f) { + if (err) + *err = xstrdup(strerror(errno)); + return -1; + } + if (len && fwrite(data, 1, len, f) != len) { + fclose(f); + if (err) + *err = xstrdup("write error"); + return -1; + } + if (fclose(f) != 0) { + if (err) + *err = xstrdup("close error"); + return -1; + } + return 0; +} + +bool path_exists(const char *path) { + struct stat st; + return stat(path, &st) == 0; +} + +const char *llsh_ca_bundle(void) { + const char *env = getenv("SSL_CERT_FILE"); + if (env && env[0] && access(env, R_OK) == 0) + return env; + env = getenv("CURL_CA_BUNDLE"); + if (env && env[0] && access(env, R_OK) == 0) + return env; + static const char *const candidates[] = { + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", /* RHEL/Fedora */ + "/etc/ssl/cert.pem", /* -> the above */ + "/etc/pki/tls/certs/ca-bundle.crt", /* older RHEL */ + "/etc/ssl/certs/ca-certificates.crt", /* Debian/Ubuntu */ + "/etc/ssl/certs/ca-bundle.crt", + }; + for (size_t i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) + if (access(candidates[i], R_OK) == 0) + return candidates[i]; + return NULL; +} + +int mkdir_p(const char *path) { + char *tmp = xstrdup(path); + int rc = 0; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + if (mkdir(tmp, 0755) != 0 && errno != EEXIST) { + rc = -1; + break; + } + *p = '/'; + } + } + if (rc == 0 && mkdir(tmp, 0755) != 0 && errno != EEXIST) + rc = -1; + free(tmp); + return rc; +} + +char *expand_tilde(const char *s) { + if (strcmp(s, "~") == 0) { + const char *home = getenv("HOME"); + return xstrdup(home ? home : "/"); + } + if (strncmp(s, "~/", 2) == 0) { + const char *home = getenv("HOME"); + if (home) + return xstrjoin2(home, s + 1); + return xstrdup(s + 1); /* shell fallback: ~/x -> /x */ + } + return xstrdup(s); +} + +/* ------------------------------------------------------------------ */ +/* Misc */ +/* ------------------------------------------------------------------ */ + +double now_secs(void) { + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec + (double)ts.tv_nsec / 1e9; +} + +void sleep_ms(unsigned long ms) { + struct timespec ts; + ts.tv_sec = ms / 1000; + ts.tv_nsec = (ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) == -1) { + /* EINTR: retry with remaining time */ + } +} + +char *itoa_dup(long long v) { + char tmp[32]; + snprintf(tmp, sizeof tmp, "%lld", v); + return xstrdup(tmp); +} + +char *dtoa_dup(double v) { + char tmp[64]; + /* Only cast to long long when v is a finite, in-range whole number; the + * cast itself is undefined for NaN/inf or out-of-range values. */ + int is_int = (v == v) && (v > -9.0e18) && (v < 9.0e18) && (v == (long long)v); + if (is_int) { + snprintf(tmp, sizeof tmp, "%lld", (long long)v); + } else { + snprintf(tmp, sizeof tmp, "%.15g", v); + } + return xstrdup(tmp); +} diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..41c8203 --- /dev/null +++ b/src/util.h @@ -0,0 +1,110 @@ +#ifndef LLSH_UTIL_H +#define LLSH_UTIL_H + +#include +#include +#include +#include + +/* ------------------------------------------------------------------ */ +/* Growable byte buffer (NUL-terminated on steal) */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char *data; + size_t len; + size_t cap; +} xbuf; + +void xbuf_init(xbuf *b); +void xbuf_free(xbuf *b); +void xbuf_reset(xbuf *b); +void xbuf_put(xbuf *b, const char *s); +void xbuf_putn(xbuf *b, const char *s, size_t n); +void xbuf_push(xbuf *b, char c); +int xbuf_fmt(xbuf *b, const char *fmt, ...); +char *xbuf_steal(xbuf *b); /* NUL-terminated string; buffer reset */ + +/* curl write callback that appends to an xbuf (pass `&buf` as the userdata). */ +size_t xbuf_curl_write(char *data, size_t sz, size_t nm, void *ud); + +/* ------------------------------------------------------------------ */ +/* Strings */ +/* ------------------------------------------------------------------ */ + +char *xstrdup(const char *s); +char *xstrndup(const char *s, size_t n); +int xstreq(const char *a, const char *b); +/* Trim ASCII whitespace from [s, s+n): returns a pointer into s and sets + * *out_len (no copy). */ +const char *xtrim_span(const char *s, size_t n, size_t *out_len); +/* Rust str::trim whitespace set. */ +bool utf8_is_ws(uint32_t cp); +char *xstr_ltrim_dup(const char *s); +char *xstr_rtrim_dup(const char *s); +char *xstr_trim_dup(const char *s); +char *xstrjoin2(const char *a, const char *b); +#include +char *xvsnprintf(const char *fmt, va_list ap); +/* Variadic convenience wrapper: freshly allocated, printf-style. */ +char *xstrfmt(const char *fmt, ...); +/* UTF-8 aware helpers */ +size_t utf8_len(const char *s); /* codepoint count */ +size_t utf8_byte_len(const char *s, size_t max_points); +const char *utf8_next(const char *p, const char *end, size_t *cp); +size_t utf8_display_width(const char *s); /* CJK fullwidth aware */ +bool utf8_is_valid(const char *s, size_t len); + +/* ------------------------------------------------------------------ */ +/* String list */ +/* ------------------------------------------------------------------ */ + +typedef struct { + char **items; + size_t len; + size_t cap; +} strlist; + +void sl_init(strlist *l); +void sl_free(strlist *l); +void sl_push(strlist *l, char *owned); /* takes ownership */ +void sl_push_copy(strlist *l, const char *s); +char *sl_pop_front(strlist *l); /* NULL when empty */ + +/* ------------------------------------------------------------------ */ +/* Files */ +/* ------------------------------------------------------------------ */ + +int read_file_to_buf(const char *path, char **out, size_t *out_len, char **err); +int read_file_to_buf_limited(const char *path, size_t max_bytes, char **out, + size_t *out_len, char **err); +int write_file_from_buf(const char *path, const char *data, size_t len, char **err); +bool path_exists(const char *path); +/* Create `path` and any missing parent directories (0755). 0 on success. */ +int mkdir_p(const char *path); + +/* Expand a leading `~` / `~/...` against $HOME (like a shell). + * Returns a freshly allocated string. */ +char *expand_tilde(const char *s); + +/* Resolve the system CA certificate bundle to pass as CURLOPT_CAINFO. + * The statically linked vendored OpenSSL has no usable default cert path + * (its --prefix points into the vendor tree, where no bundle is installed), + * so without this libcurl cannot verify any TLS peer. Honors + * $SSL_CERT_FILE / $CURL_CA_BUNDLE, then probes the usual distro locations. + * Returns NULL when nothing readable is found. */ +const char *llsh_ca_bundle(void); + +/* ------------------------------------------------------------------ */ +/* Misc */ +/* ------------------------------------------------------------------ */ + +double now_secs(void); +void sleep_ms(unsigned long ms); +void *xmalloc(size_t n); +void *xrealloc(void *p, size_t n); +char *itoa_dup(long long v); +/* Format a double the way Rust's Display does for common values */ +char *dtoa_dup(double v); + +#endif diff --git a/src/verbose.cpp b/src/verbose.cpp new file mode 100644 index 0000000..5de886f --- /dev/null +++ b/src/verbose.cpp @@ -0,0 +1,55 @@ +#include "verbose.h" + +#include +#include +#include + +static bool g_verbose = false; +static void (*g_sink)(const char *, void *) = NULL; +static void *g_sink_ud = NULL; + +void verbose_set(bool on) { + g_verbose = on; +} + +bool verbose_enabled(void) { + return g_verbose; +} + +void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud) { + g_sink = sink; + g_sink_ud = ud; +} + +void verbose_log(const char *event, const vlog_kv *kvs, size_t n) { + if (!g_verbose) + return; + cJSON *obj = cJSON_CreateObject(); + cJSON_AddStringToObject(obj, "event", event); + for (size_t i = 0; i < n && kvs; i++) { + const vlog_kv *kv = &kvs[i]; + switch (kv->kind) { + case VLOG_STR: + cJSON_AddStringToObject(obj, kv->key, kv->s ? kv->s : ""); + break; + case VLOG_INT: + cJSON_AddNumberToObject(obj, kv->key, kv->i); + break; + case VLOG_BOOL: + cJSON_AddBoolToObject(obj, kv->key, kv->b); + break; + case VLOG_DBL: + cJSON_AddNumberToObject(obj, kv->key, kv->d); + break; + } + } + char *line = cJSON_PrintUnformatted(obj); + if (line) { + if (g_sink) + g_sink(line, g_sink_ud); + else + fprintf(stderr, "%s\n", line); + free(line); + } + cJSON_Delete(obj); +} diff --git a/src/verbose.h b/src/verbose.h new file mode 100644 index 0000000..35c2002 --- /dev/null +++ b/src/verbose.h @@ -0,0 +1,35 @@ +#ifndef LLSH_VERBOSE_H +#define LLSH_VERBOSE_H + +#include "util.h" + +/* Structured event logging (one JSON object per line) to stderr, + * enabled via --verbose / config. Mirrors verbose.rs. */ + +typedef enum { + VLOG_STR, + VLOG_INT, + VLOG_BOOL, + VLOG_DBL, +} vlog_kind; + +typedef struct { + const char *key; + vlog_kind kind; + const char *s; + long long i; + double d; + bool b; +} vlog_kv; + +#define VLOG_STR(k, v) { (k), VLOG_STR, (v), 0, 0, false } +#define VLOG_INT(k, v) { (k), VLOG_INT, NULL, (long long)(v), 0, false } +#define VLOG_BOOL(k, v) { (k), VLOG_BOOL, NULL, 0, 0, (v) } +#define VLOG_DBL(k, v) { (k), VLOG_DBL, NULL, 0, (v), false } + +void verbose_set(bool on); +bool verbose_enabled(void); +void verbose_set_sink(void (*sink)(const char *line, void *ud), void *ud); +void verbose_log(const char *event, const vlog_kv *kvs, size_t n); + +#endif -- cgit v1.3.1