summaryrefslogtreecommitdiffstats
path: root/include
diff options
context:
space:
mode:
authorEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
committerEthan Werner <mail@ethanwerner.com>2026-09-19 06:09:54 +0000
commitbf19b9938508f70011395f4dc283ca758ec6a8d3 (patch)
tree46dc4c442ed8d8d43040433c6a4794e76f8e618a /include
parent96ecf29e8be977f51f351308ef2c7c492bb829aa (diff)
llm toolingHEADmaster
Diffstat (limited to 'include')
-rw-r--r--include/clio.h1493
-rw-r--r--include/dotagents.h1282
-rwxr-xr-xinclude/escape.h88
-rw-r--r--include/frontmatter.h1058
-rw-r--r--include/llm.h1539
5 files changed, 5460 insertions, 0 deletions
diff --git a/include/clio.h b/include/clio.h
new file mode 100644
index 0000000..8899197
--- /dev/null
+++ b/include/clio.h
@@ -0,0 +1,1493 @@
+/* clio.h — minimal pseudo-TUI layout layer over escape.h.
+ *
+ * escape.h owns styling (SGR, cursor motion, clear, color); clio.h owns
+ * layout: where text goes, how it wraps and clips, and how a composed
+ * frame is blitted to the terminal with the minimal diff of escape
+ * sequences. Apps never emit raw escape codes themselves.
+ *
+ * Model:
+ * clio_term the real terminal: size, capabilities, raw mode, alt
+ * screen, cursor, bracketed-paste/kitty state, key queue,
+ * and the last flushed frame (for diffing).
+ * clio_buf an in-memory cell grid (the canvas). Apps compose frames
+ * here; nothing is written until clio_flush.
+ * clio_flush diff the canvas against the previous frame and blit only
+ * changed rows through escape.h. Non-tty (pipe, CI) falls
+ * back to plain text lines so piped output stays clean.
+ * input a pure, chunk-fed key decoder (testable without a tty),
+ * raw-mode key reading with a bounded queue, and a
+ * single-line editor (clio_readline) that renders into the
+ * canvas.
+ *
+ * Conventions: C99, header-only (static inline), no global state,
+ * extern "C", POSIX-first (termios/poll/TIOCGWINSZ) — Windows is a
+ * documented limitation, as in escape.h.
+ *
+ * Usage: #include "clio.h". The header is complete on its own; escape.h
+ * is pulled in for the styling layer. The esc_* function symbols need one
+ * defining TU with ESCAPE_IMPLEMENTATION (llshcpp's src/escape.c, or the
+ * test TU itself for zero-link builds).
+ *
+ * Layout is column-based: one cell per column, one code point per cell.
+ * Wide characters (width 2) occupy one cell; a wide character landing
+ * in the last column may be clipped by the terminal (v1 limitation).
+ *
+ * API:
+ * style: clio_style, clio_style_default, clio_style_eq
+ * text: clio_utf8_next_n, clio_utf8_enc, clio_cp_width,
+ * clio_text_width, clio_char_start, clio_char_end,
+ * clio_word_back
+ * canvas: clio_buf, clio_cell, clio_buf_init/free/clear,
+ * clio_buf_at, clio_buf_clear_region, clio_buf_fill_region,
+ * clio_buf_put, clio_buf_box, clio_buf_scroll_up
+ * keys: clio_key, CLIOK_*, clio_keydec, clio_keydec_init,
+ * clio_keydec_feed, clio_keydec_flush
+ * terminal: clio_term, clio_color_policy, clio_term_init,
+ * clio_term_resize, clio_term_raw_on/off,
+ * clio_term_alt_on/off, clio_term_cursor_hide/show,
+ * clio_term_paste_on/off, clio_term_kitty_on/off,
+ * clio_term_bell, clio_term_shutdown, clio_term_free
+ * output: clio_fstyle, clio_flush, clio_cursor
+ * input: clio_next_key, clio_next_key_stdin, clio_read_key,
+ * clio_readline
+ *
+ * Concurrency: no shared state; each clio_term is independent.
+ */
+#ifndef CLIO_H
+#define CLIO_H
+
+#include "escape.h"
+#include "type.h"
+
+#include <ctype.h>
+#include <limits.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+#include <wchar.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Style */
+/* ------------------------------------------------------------------ */
+
+/* Attribute bits (OR-combined into clio_style.attr). */
+enum {
+ CLIO_BOLD = 1u,
+ CLIO_DIM = 2u,
+ CLIO_ITALIC = 4u,
+ CLIO_UNDERLINE = 8u,
+ CLIO_REVERSE = 16u,
+ CLIO_STRIKE = 32u,
+};
+
+/* fg/bg: -1 = terminal default, otherwise a 0..255 palette index.
+ * attr: OR of CLIO_* bits. */
+typedef struct clio_style {
+ int fg;
+ int bg;
+ unsigned attr;
+} clio_style;
+
+static inline clio_style clio_style_default(void) {
+ clio_style s;
+ s.fg = -1;
+ s.bg = -1;
+ s.attr = 0;
+ return s;
+}
+
+static inline int clio_style_eq(const clio_style *a, const clio_style *b) {
+ return a->fg == b->fg && a->bg == b->bg && a->attr == b->attr;
+}
+
+/* ------------------------------------------------------------------ */
+/* Text (UTF-8 + display width) */
+/* ------------------------------------------------------------------ */
+
+/* Decode the next code point in [p, end); returns the byte length used.
+ * Truncated or invalid sequences still consume at least one byte. */
+static inline size_t clio_utf8_next_n(const char *p, const char *end, u32 *cp) {
+ unsigned char b = (unsigned char) p[0];
+ size_t n;
+ if (b < 0x80)
+ n = 1;
+ else if ((b & 0xE0) == 0xC0)
+ n = 2;
+ else if ((b & 0xF0) == 0xE0)
+ n = 3;
+ else if ((b & 0xF8) == 0xF0)
+ n = 4;
+ else {
+ *cp = 0;
+ return 1;
+ }
+ if ((size_t)(end - p) < n)
+ n = (size_t)(end - p);
+ u32 c = 0;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char ch = (unsigned char) p[i];
+ unsigned int mask =
+ (i == 0) ? (n == 1 ? 0x7Fu : n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u)
+ : 0x3Fu;
+ c = (c << 6) | (u32)(ch & mask);
+ }
+ *cp = c;
+ return n;
+}
+
+static inline size_t clio_utf8_next(const char *s, u32 *cp) {
+ return clio_utf8_next_n(s, s + strlen(s), cp);
+}
+
+/* Encode a code point into out (at least 5 bytes). -1 on invalid input. */
+static inline int clio_utf8_enc(u32 cp, char *out) {
+ size_t n;
+ if (cp < 0x80)
+ n = 1;
+ else if (cp < 0x800)
+ n = 2;
+ else if (cp < 0x10000)
+ n = 3;
+ else if (cp <= 0x10FFFF)
+ n = 4;
+ else
+ return -1;
+ if (cp >= 0xD800 && cp <= 0xDFFF)
+ return -1;
+ unsigned char b[4];
+ switch (n) {
+ case 1:
+ b[0] = (unsigned char) cp;
+ break;
+ case 2:
+ b[0] = (unsigned char)(0xC0 | (cp >> 6));
+ b[1] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ case 3:
+ b[0] = (unsigned char)(0xE0 | (cp >> 12));
+ b[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[2] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ default:
+ b[0] = (unsigned char)(0xF0 | (cp >> 18));
+ b[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F));
+ b[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F));
+ b[3] = (unsigned char)(0x80 | (cp & 0x3F));
+ break;
+ }
+ memcpy(out, b, n);
+ out[n] = '\0';
+ return (int) n;
+}
+
+/* Display width of a code point, locale-independent (wcwidth is not:
+ * glibc's C locale reports 0 for CJK, musl reports 2 — we must be the
+ * same everywhere). 0 = zero-width, 2 = wide (East Asian Wide/Fullwidth
+ * + common emoji), 1 = everything else. Simplified W=|F table. */
+static inline size_t clio_cp_width(u32 cp) {
+ if (cp == 0 || cp < 0x20 || (cp >= 0x7F && cp <= 0x9F))
+ return 0;
+ if (cp == 0xAD || cp == 0x200B || cp == 0x200C || cp == 0x200D ||
+ cp == 0x2060 || (cp >= 0x300 && cp <= 0x36F))
+ return 0;
+ if ((cp >= 0x1100 && cp <= 0x115F) || (cp >= 0x2E80 && cp <= 0x303E) ||
+ (cp >= 0x3041 && cp <= 0x33FF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
+ (cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0xA000 && cp <= 0xA4CF) ||
+ (cp >= 0xA960 && cp <= 0xA97F) || (cp >= 0xAC00 && cp <= 0xD7A3) ||
+ (cp >= 0xF900 && cp <= 0xFAFF) || (cp >= 0xFE10 && cp <= 0xFE19) ||
+ (cp >= 0xFE30 && cp <= 0xFE6F) || (cp >= 0xFF00 && cp <= 0xFF60) ||
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || (cp >= 0x1F300 && cp <= 0x1F64F) ||
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || (cp >= 0x20000 && cp <= 0x3FFFD))
+ return 2;
+ return 1;
+}
+
+static inline size_t clio_text_width_n(const char *s, size_t n) {
+ size_t w = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0)
+ w += clio_cp_width(cp);
+ p += len;
+ }
+ return w;
+}
+
+static inline size_t clio_text_width(const char *s) {
+ return clio_text_width_n(s, strlen(s));
+}
+
+/* Byte offset of the start of the code point containing pos. */
+static inline size_t clio_char_start(const char *s, size_t pos) {
+ if (pos == 0)
+ return 0;
+ size_t i = pos;
+ while (i > 0 && (((unsigned char) s[i]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset of the start of the code point immediately before pos
+ * (pos is a cursor position, i.e. a char boundary). */
+static inline size_t clio_char_before(const char *s, size_t pos) {
+ size_t i = pos;
+ if (i == 0)
+ return 0;
+ i--; /* last byte belongs to the previous code point */
+ while (i > 0 && (((unsigned char) s[i - 1]) & 0xC0) == 0x80)
+ i--;
+ return i;
+}
+
+/* Byte offset just past the code point at pos. */
+static inline size_t clio_char_end(const char *s, size_t len, size_t pos) {
+ if (pos >= len)
+ return len;
+ u32 cp;
+ size_t n = clio_utf8_next_n(s + pos, s + len, &cp);
+ return pos + n;
+}
+
+/* Byte offset of the start of the word before pos (ed-style Ctrl+W). */
+static inline size_t clio_word_back(const char *s, size_t pos) {
+ size_t i = pos;
+ 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;
+}
+
+/* ------------------------------------------------------------------ */
+/* Canvas (cell grid) */
+/* ------------------------------------------------------------------ */
+
+/* One column of a row. ch is a code point (0 = blank slot; bg/attr still
+ * apply). fg/bg of 0xFFFF mean "terminal default". A blank default cell
+ * is {0, 0xFFFF, 0xFFFF, 0}. */
+typedef struct clio_cell {
+ u32 ch;
+ u16 fg;
+ u16 bg;
+ u16 attr;
+} clio_cell;
+
+typedef struct clio_buf {
+ clio_cell *cells;
+ size_t rows;
+ size_t cols;
+} clio_buf;
+
+static inline u16 clio_style_u16(int v) {
+ return (u16)(v < 0 ? 0xFFFF : (v > 255 ? 255 : v));
+}
+
+static inline void clio_cell_set(clio_cell *c, u32 ch, clio_style st) {
+ c->ch = ch;
+ c->fg = clio_style_u16(st.fg);
+ c->bg = clio_style_u16(st.bg);
+ c->attr = (u16) st.attr;
+}
+
+static inline clio_style clio_cell_style(const clio_cell *c) {
+ clio_style s;
+ s.fg = (c->fg == 0xFFFF) ? -1 : (int) c->fg;
+ s.bg = (c->bg == 0xFFFF) ? -1 : (int) c->bg;
+ s.attr = c->attr;
+ return s;
+}
+
+static inline int clio_cell_eq(const clio_cell *a, const clio_cell *b) {
+ return memcmp(a, b, sizeof(*a)) == 0;
+}
+
+static inline int clio_buf_init(clio_buf *b, size_t rows, size_t cols) {
+ if (rows == 0)
+ rows = 1;
+ if (cols == 0)
+ cols = 1;
+ b->rows = rows;
+ b->cols = cols;
+ b->cells = (clio_cell *) calloc(rows * cols, sizeof(clio_cell));
+ if (!b->cells) {
+ b->rows = b->cols = 0;
+ return -1;
+ }
+ for (size_t i = 0; i < rows * cols; i++) {
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+ return 0;
+}
+
+static inline void clio_buf_free(clio_buf *b) {
+ free(b->cells);
+ b->cells = NULL;
+ b->rows = b->cols = 0;
+}
+
+static inline void clio_buf_clear(clio_buf *b) {
+ for (size_t i = 0; i < b->rows * b->cols; i++) {
+ b->cells[i].ch = 0;
+ b->cells[i].attr = 0;
+ b->cells[i].fg = 0xFFFF;
+ b->cells[i].bg = 0xFFFF;
+ }
+}
+
+static inline clio_cell *clio_buf_at(clio_buf *b, size_t row, size_t col) {
+ if (row >= b->rows || col >= b->cols)
+ return NULL;
+ return &b->cells[row * b->cols + col];
+}
+
+static inline void clio_buf_clear_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+static inline void clio_buf_fill_region(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1, u32 ch,
+ clio_style st) {
+ if (r1 < r0 || c1 < c0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows - 1)
+ r1 = b->rows - 1;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r <= r1; r++)
+ for (size_t c = c0; c <= c1; c++)
+ clio_cell_set(&b->cells[r * b->cols + c], ch, st);
+}
+
+/* Write text at (row, col); wraps onto the next row (when wrap != 0 and
+ * rows remain) and clips at the buffer edges. '\n' moves to the next row
+ * at column 0. Zero-width code points occupy no cell. Returns the number
+ * of bytes written (including newlines). */
+static inline size_t clio_buf_put(clio_buf *b, size_t row, size_t col,
+ const char *text, clio_style st, int wrap) {
+ size_t written = 0;
+ size_t len = strlen(text);
+ const char *p = text;
+ while (p < text + len) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, text + len, &cp);
+ p += n;
+ if (cp == '\n') {
+ row++;
+ col = 0;
+ if (row >= b->rows)
+ break;
+ written += n;
+ continue;
+ }
+ size_t w = clio_cp_width(cp);
+ if (w == 0) {
+ written += n;
+ continue;
+ }
+ if (col + w > b->cols) {
+ if (!wrap || row + 1 >= b->rows)
+ break;
+ row++;
+ col = 0;
+ }
+ for (size_t i = 0; i < w; i++) {
+ if (col + i >= b->cols)
+ break;
+ clio_cell *cell = &b->cells[row * b->cols + (col + i)];
+ cell->ch = (i == 0) ? cp : 0;
+ cell->fg = clio_style_u16(st.fg);
+ cell->bg = clio_style_u16(st.bg);
+ cell->attr = (u16) st.attr;
+ }
+ col += w;
+ written += n;
+ }
+ return written;
+}
+
+/* Draw a single-character border (ch, e.g. U+2502) around the rectangle
+ * (r0, c0) with width w and height h. */
+static inline void clio_buf_box(clio_buf *b, size_t r0, size_t c0, size_t w,
+ size_t h, u32 ch, clio_style st) {
+ if (r0 >= b->rows || c0 >= b->cols || w == 0 || h == 0)
+ return;
+ if (w > b->cols - c0)
+ w = b->cols - c0;
+ if (h > b->rows - r0)
+ h = b->rows - r0;
+ for (size_t x = 0; x < w; x++) {
+ clio_buf_fill_region(b, r0, c0 + x, r0, c0 + x, ch, st);
+ clio_buf_fill_region(b, r0 + h - 1, c0 + x, r0 + h - 1, c0 + x, ch, st);
+ }
+ for (size_t y = 1; y + 1 < h; y++) {
+ clio_buf_fill_region(b, r0 + y, c0, r0 + y, c0, ch, st);
+ clio_buf_fill_region(b, r0 + y, c0 + w - 1, r0 + y, c0 + w - 1, ch, st);
+ }
+}
+
+/* Shift rows [r0, r1) up by one row within columns [c0, c1]; the bottom
+ * row becomes blank. */
+static inline void clio_buf_scroll_up(clio_buf *b, size_t r0, size_t c0,
+ size_t r1, size_t c1) {
+ if (r1 <= r0 || r0 >= b->rows || c0 >= b->cols)
+ return;
+ if (r1 > b->rows)
+ r1 = b->rows;
+ if (c1 > b->cols - 1)
+ c1 = b->cols - 1;
+ for (size_t r = r0; r + 1 <= r1; r++)
+ memcpy(&b->cells[r * b->cols + c0], &b->cells[(r + 1) * b->cols + c0],
+ (c1 - c0 + 1) * sizeof(clio_cell));
+ for (size_t c = c0; c <= c1; c++) {
+ clio_cell *cell = &b->cells[r1 * b->cols + c];
+ cell->ch = 0;
+ cell->attr = 0;
+ cell->fg = 0xFFFF;
+ cell->bg = 0xFFFF;
+ }
+}
+
+/* ------------------------------------------------------------------ */
+/* Key types + pure decoder */
+/* ------------------------------------------------------------------ */
+
+typedef struct clio_key {
+ int kind;
+ u32 cp; /* CLIOK_CHAR: the code point */
+} clio_key;
+
+enum {
+ CLIOK_UNKNOWN = 0,
+ CLIOK_CHAR,
+ CLIOK_ENTER,
+ CLIOK_TAB,
+ CLIOK_BACKTAB,
+ CLIOK_BACKSPACE,
+ CLIOK_DELETE,
+ CLIOK_LEFT,
+ CLIOK_RIGHT,
+ CLIOK_UP,
+ CLIOK_DOWN,
+ CLIOK_HOME,
+ CLIOK_END,
+ CLIOK_PAGEUP,
+ CLIOK_PAGEDOWN,
+ CLIOK_ESC,
+ CLIOK_CTRL_A,
+ CLIOK_CTRL_C,
+ CLIOK_CTRL_D,
+ CLIOK_CTRL_E,
+ CLIOK_CTRL_K,
+ CLIOK_CTRL_L,
+ CLIOK_CTRL_U,
+ CLIOK_CTRL_W,
+ CLIOK_CTRL_Y,
+ CLIOK_CTRL_Z,
+ CLIOK_CTRL_G,
+};
+
+/* State machine for decoding raw terminal input into clio_key values.
+ * Pure: no I/O, no allocation. Feed chunks of any size (byte boundaries
+ * may split UTF-8 or escape sequences); state persists across feeds. */
+typedef struct clio_keydec {
+ int state; /* 0 ground, 1 escape, 2 csi, 3 ss3 */
+ int utf8_n;
+ int utf8_need;
+ u32 utf8[4];
+ int csi_n;
+ unsigned char csi[32];
+ int pasting;
+} clio_keydec;
+
+static inline void clio_keydec_init(clio_keydec *d) {
+ memset(d, 0, sizeof *d);
+}
+
+static inline int clio_kd_ground(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (b == 0x1B) {
+ d->state = 1;
+ return 0;
+ }
+ if (b == 0x0D) {
+ k->kind = CLIOK_ENTER;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x0A) {
+ if (d->pasting) {
+ k->kind = CLIOK_CHAR;
+ k->cp = '\n';
+ return 1;
+ }
+ return 0; /* bare LF outside paste: dead key */
+ }
+ if (b == 0x7F || b == 0x08) {
+ k->kind = CLIOK_BACKSPACE;
+ k->cp = 0;
+ return 1;
+ }
+ if (b == 0x09) {
+ k->kind = CLIOK_TAB;
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x20) {
+ switch (b) {
+ case 0x01u:
+ k->kind = CLIOK_CTRL_A;
+ break;
+ case 0x03u:
+ k->kind = CLIOK_CTRL_C;
+ d->pasting = 0; /* Ctrl+C interrupts a dangling paste */
+ break;
+ case 0x04u:
+ k->kind = CLIOK_CTRL_D;
+ break;
+ case 0x05u:
+ k->kind = CLIOK_CTRL_E;
+ break;
+ case 0x0Bu:
+ k->kind = CLIOK_CTRL_K;
+ break;
+ case 0x0Cu:
+ k->kind = CLIOK_CTRL_L;
+ break;
+ case 0x15u:
+ k->kind = CLIOK_CTRL_U;
+ break;
+ case 0x17u:
+ k->kind = CLIOK_CTRL_W;
+ break;
+ case 0x19u:
+ k->kind = CLIOK_CTRL_Y;
+ break;
+ case 0x1Au:
+ k->kind = CLIOK_CTRL_Z;
+ break;
+ case 0x07u:
+ k->kind = CLIOK_CTRL_G;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ }
+ if (b < 0x80) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ if (b >= 0xC0 && b < 0xF8) {
+ d->utf8_need = (b >= 0xF0) ? 4 : (b >= 0xE0) ? 3 : 2;
+ d->utf8[0] = b;
+ d->utf8_n = 1;
+ return 0;
+ }
+ if (d->utf8_n > 0 && b >= 0x80 && b < 0xC0) {
+ d->utf8[d->utf8_n++] = b;
+ if (d->utf8_n < d->utf8_need)
+ return 0;
+ int n = d->utf8_n;
+ d->utf8_n = 0;
+ u32 cp =
+ d->utf8[0] & (n == 2 ? 0x1Fu : n == 3 ? 0x0Fu : 0x07u);
+ for (int i = 1; i < n; i++)
+ cp = (cp << 6) | (d->utf8[i] & 0x3Fu);
+ k->kind = CLIOK_CHAR;
+ k->cp = cp;
+ return 1;
+ }
+ d->utf8_n = 0;
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = b;
+ return 1;
+}
+
+static inline int clio_kd_escape(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ if (b == '[') {
+ d->state = 2;
+ d->csi_n = 0;
+ return 0;
+ }
+ if (b == 'O') {
+ d->state = 3;
+ return 0;
+ }
+ if (b >= 0x20 && b <= 0x7E) {
+ k->kind = CLIOK_CHAR;
+ k->cp = b;
+ return 1;
+ }
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_csi(clio_keydec *d, unsigned char b, clio_key *k) {
+ if (d->csi_n < (int) sizeof d->csi)
+ d->csi[d->csi_n++] = b;
+ if ((b >= '0' && b <= '9') || b == ';' || b == '?' || b == '<' || b == '=' ||
+ b == '>')
+ return 0;
+ d->state = 0;
+ unsigned long num = 0;
+ int any = 0;
+ for (int i = 0; i < d->csi_n - 1; i++) {
+ unsigned char p = d->csi[i];
+ if (p < '0' || p > '9')
+ break;
+ if (any)
+ num = num * 10 + (unsigned long)(p - '0');
+ else
+ num = (unsigned long)(p - '0');
+ any = 1;
+ if (num > 0xFFFFu)
+ break;
+ }
+ d->csi_n = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ k->cp = 0;
+ return 1;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ k->cp = 0;
+ return 1;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ k->cp = 0;
+ return 1;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ k->cp = 0;
+ return 1;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ k->cp = 0;
+ return 1;
+ case 'F':
+ k->kind = CLIOK_END;
+ k->cp = 0;
+ return 1;
+ case 'Z':
+ k->kind = CLIOK_BACKTAB;
+ k->cp = 0;
+ return 1;
+ case '~':
+ switch (num) {
+ case 1u:
+ case 7u:
+ k->kind = CLIOK_HOME;
+ break;
+ case 3u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 4u:
+ case 8u:
+ k->kind = CLIOK_END;
+ break;
+ case 5u:
+ k->kind = CLIOK_PAGEUP;
+ break;
+ case 6u:
+ k->kind = CLIOK_PAGEDOWN;
+ break;
+ case 200u:
+ d->pasting = 1;
+ return 0;
+ case 201u:
+ d->pasting = 0;
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+ case 'u': /* kitty keyboard protocol */
+ switch (num) {
+ case 9u:
+ k->kind = CLIOK_TAB;
+ break;
+ case 12u:
+ k->kind = CLIOK_BACKSPACE;
+ break;
+ case 13u:
+ k->kind = CLIOK_ENTER;
+ break;
+ case 27u:
+ k->kind = CLIOK_ESC;
+ break;
+ case 127u:
+ k->kind = CLIOK_DELETE;
+ break;
+ case 0x2190u:
+ k->kind = CLIOK_LEFT;
+ break;
+ case 0x2191u:
+ k->kind = CLIOK_UP;
+ break;
+ case 0x2192u:
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 0x2193u:
+ k->kind = CLIOK_DOWN;
+ break;
+ default:
+ k->cp = 0;
+ return 0;
+ }
+ k->cp = 0;
+ return 1;
+ case 'R': /* cursor position report: swallow */
+ case 'M':
+ case 'h':
+ case 'l':
+ case 'm':
+ return 0;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ k->cp = 0;
+ return 1;
+ }
+}
+
+static inline int clio_kd_ss3(clio_keydec *d, unsigned char b, clio_key *k) {
+ d->state = 0;
+ switch (b) {
+ case 'A':
+ k->kind = CLIOK_UP;
+ break;
+ case 'B':
+ k->kind = CLIOK_DOWN;
+ break;
+ case 'C':
+ k->kind = CLIOK_RIGHT;
+ break;
+ case 'D':
+ k->kind = CLIOK_LEFT;
+ break;
+ case 'H':
+ k->kind = CLIOK_HOME;
+ break;
+ case 'F':
+ k->kind = CLIOK_END;
+ break;
+ default:
+ k->kind = CLIOK_UNKNOWN;
+ break;
+ }
+ k->cp = 0;
+ return 1;
+}
+
+static inline int clio_kd_step(clio_keydec *d, unsigned char b, clio_key *k) {
+ switch (d->state) {
+ case 1:
+ return clio_kd_escape(d, b, k);
+ case 2:
+ return clio_kd_csi(d, b, k);
+ case 3:
+ return clio_kd_ss3(d, b, k);
+ default:
+ return clio_kd_ground(d, b, k);
+ }
+}
+
+/* Feed raw bytes; emits up to cap decoded keys into out; returns the
+ * number emitted. */
+static inline size_t clio_keydec_feed(clio_keydec *d, const unsigned char *in,
+ size_t n, clio_key *out, size_t cap) {
+ size_t emitted = 0;
+ for (size_t i = 0; i < n; i++) {
+ clio_key k;
+ if (clio_kd_step(d, in[i], &k) && emitted < cap)
+ out[emitted++] = k;
+ }
+ return emitted;
+}
+
+/* Emit a trailing bare ESC (state 1) as a key. 1 if emitted. */
+static inline int clio_keydec_flush(clio_keydec *d, clio_key *k) {
+ if (d->state != 1)
+ return 0;
+ d->state = 0;
+ k->kind = CLIOK_ESC;
+ k->cp = 0;
+ return 1;
+}
+
+/* ------------------------------------------------------------------ */
+/* Terminal state */
+/* ------------------------------------------------------------------ */
+
+#define CLIO_KEYQ 32
+
+typedef struct clio_term {
+ int is_tty;
+ int color; /* clio_color_policy() result */
+ size_t rows;
+ size_t cols;
+ int raw;
+ struct termios saved;
+ int alt;
+ int cursor_hidden;
+ int paste;
+ int kitty;
+ clio_keydec dec;
+ clio_key q[CLIO_KEYQ];
+ size_t qh;
+ size_t qt;
+ clio_buf last;
+ int has_last;
+} clio_term;
+
+/* CLICOLOR_FORCE (non-empty, not "0") wins; otherwise color only on a tty
+ * without NO_COLOR, TERM=dumb, or CI. */
+static inline int clio_color_policy(int tty) {
+ const char *force = getenv("CLICOLOR_FORCE");
+ if (force && force[0]) {
+ if (strcmp(force, "0") == 0)
+ return 0;
+ return 1;
+ }
+ if (!tty)
+ return 0;
+ if (getenv("NO_COLOR"))
+ return 0;
+ const char *term = getenv("TERM");
+ if (term && strcmp(term, "dumb") == 0)
+ return 0;
+ if (getenv("CI"))
+ return 0;
+ return 1;
+}
+
+static inline int clio_term_init(clio_term *t) {
+ memset(t, 0, sizeof *t);
+ t->is_tty = isatty(STDIN_FILENO) != 0 && isatty(STDOUT_FILENO) != 0;
+ t->color = clio_color_policy(t->is_tty);
+ t->rows = 24;
+ t->cols = 80;
+ struct winsize ws;
+ if (t->is_tty && ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 &&
+ ws.ws_row > 0 && ws.ws_col > 0) {
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ }
+ clio_keydec_init(&t->dec);
+ return 0;
+}
+
+/* Re-read the window size; invalidates the flushed-frame cache. -1 on
+ * failure (previous size kept). */
+static inline int clio_term_resize(clio_term *t) {
+ struct winsize ws;
+ if (!t->is_tty)
+ return -1;
+ if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) != 0)
+ return -1;
+ if (ws.ws_row == 0 || ws.ws_col == 0)
+ return -1;
+ t->rows = (size_t) ws.ws_row;
+ t->cols = (size_t) ws.ws_col;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+ return 0;
+}
+
+static inline int clio_term_raw_on(clio_term *t) {
+ if (t->raw || !t->is_tty)
+ return -1;
+ if (tcgetattr(STDIN_FILENO, &t->saved) != 0)
+ return -1;
+ struct termios raw = t->saved;
+ 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;
+ t->raw = 1;
+ return 0;
+}
+
+static inline void clio_term_raw_off(clio_term *t) {
+ if (!t->raw)
+ return;
+ tcsetattr(STDIN_FILENO, TCSANOW, &t->saved);
+ t->raw = 0;
+}
+
+static inline void clio_term_alt_on(clio_term *t) {
+ if (t->alt)
+ return;
+ esc_falt_screen_on(stdout);
+ fflush(stdout);
+ t->alt = 1;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_alt_off(clio_term *t) {
+ if (!t->alt)
+ return;
+ esc_falt_screen_off(stdout);
+ fflush(stdout);
+ t->alt = 0;
+ clio_buf_free(&t->last);
+ t->has_last = 0;
+}
+
+static inline void clio_term_cursor_hide(clio_term *t) {
+ if (t->cursor_hidden)
+ return;
+ esc_fcursor_hide(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 1;
+}
+
+static inline void clio_term_cursor_show(clio_term *t) {
+ if (!t->cursor_hidden)
+ return;
+ esc_fcursor_show(stdout);
+ fflush(stdout);
+ t->cursor_hidden = 0;
+}
+
+static inline void clio_term_paste_on(clio_term *t) {
+ if (t->paste)
+ return;
+ esc_fbracketed_paste_on(stdout);
+ fflush(stdout);
+ t->paste = 1;
+ t->dec.pasting = 1;
+}
+
+static inline void clio_term_paste_off(clio_term *t) {
+ if (!t->paste)
+ return;
+ esc_fbracketed_paste_off(stdout);
+ fflush(stdout);
+ t->paste = 0;
+ t->dec.pasting = 0;
+}
+
+static inline void clio_term_kitty_on(clio_term *t) {
+ if (t->kitty)
+ return;
+ esc_fkitty_on(stdout);
+ fflush(stdout);
+ t->kitty = 1;
+}
+
+static inline void clio_term_kitty_off(clio_term *t) {
+ if (!t->kitty)
+ return;
+ esc_fkitty_off(stdout);
+ fflush(stdout);
+ t->kitty = 0;
+}
+
+static inline void clio_term_bell(clio_term *t) {
+ (void) t;
+ esc_fbell(stdout);
+ fflush(stdout);
+}
+
+/* Restore everything clio_term turned on (idempotent). */
+static inline void clio_term_shutdown(clio_term *t) {
+ if (t->kitty)
+ clio_term_kitty_off(t);
+ if (t->paste)
+ clio_term_paste_off(t);
+ if (t->cursor_hidden)
+ clio_term_cursor_show(t);
+ if (t->alt)
+ clio_term_alt_off(t);
+ clio_term_raw_off(t);
+}
+
+static inline void clio_term_free(clio_term *t) {
+ clio_term_shutdown(t);
+ clio_buf_free(&t->last);
+ memset(t, 0, sizeof *t);
+}
+
+/* ------------------------------------------------------------------ */
+/* Output (diff flush) */
+/* ------------------------------------------------------------------ */
+
+/* Map a style onto the stream: reset, then attributes, then 256-color
+ * fg/bg. escape.h emits the codes; clio.h only decides what goes. */
+static inline void clio_fstyle(FILE *f, clio_style st) {
+ if (!f)
+ return;
+ fputs(ESC_RESET, f);
+ if (st.attr & CLIO_BOLD)
+ fputs(ESC_BOLD, f);
+ if (st.attr & CLIO_DIM)
+ fputs(ESC_DIM, f);
+ if (st.attr & CLIO_ITALIC)
+ fputs(ESC_ITALIC, f);
+ if (st.attr & CLIO_UNDERLINE)
+ fputs(ESC_UNDERLINE, f);
+ if (st.attr & CLIO_REVERSE)
+ fputs(ESC_REVERSE, f);
+ if (st.attr & CLIO_STRIKE)
+ fputs(ESC_STRIKETHROUGH, f);
+ if (st.fg >= 0)
+ esc_fcolor_fg_256(f, (u32)(st.fg & 0xFF));
+ if (st.bg >= 0)
+ esc_fcolor_bg_256(f, (u32)(st.bg & 0xFF));
+}
+
+/* Render one row: style runs + text. Blank cells print as spaces. */
+static inline void clio_emit_row(FILE *f, const clio_buf *b, size_t row) {
+ clio_style cur = clio_style_default();
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[row * b->cols + c];
+ clio_style st = clio_cell_style(cell);
+ if (!clio_style_eq(&st, &cur)) {
+ clio_fstyle(f, st);
+ cur = st;
+ }
+ if (cell->ch == 0) {
+ fputc(' ', f);
+ } else if (cell->ch < 0x80) {
+ fputc((int)(cell->ch & 0xFF), f);
+ } else {
+ char tmp[8];
+ if (clio_utf8_enc(cell->ch, tmp) > 0)
+ fputs(tmp, f);
+ else
+ fputc(' ', f);
+ }
+ }
+}
+
+/* Non-tty fallback: plain text lines, trailing blanks trimmed. */
+static inline void clio_flush_plain(const clio_buf *b) {
+ size_t cap = b->cols * 4 + 1;
+ char *row = (char *) malloc(cap);
+ if (!row)
+ return;
+ for (size_t r = 0; r < b->rows; r++) {
+ size_t n = 0;
+ for (size_t c = 0; c < b->cols; c++) {
+ const clio_cell *cell = &b->cells[r * b->cols + c];
+ if (cell->ch == 0) {
+ row[n++] = ' ';
+ } else if (cell->ch < 0x80) {
+ row[n++] = (char)(cell->ch & 0xFF);
+ } else {
+ int e = clio_utf8_enc(cell->ch, row + n);
+ if (e > 0)
+ n += (size_t) e;
+ else
+ row[n++] = ' ';
+ }
+ }
+ while (n > 0 && row[n - 1] == ' ')
+ n--;
+ fwrite(row, 1, n, stdout);
+ fputc('\n', stdout);
+ }
+ free(row);
+ fflush(stdout);
+}
+
+/* Blit the canvas to the terminal. TTY: diff against the previous frame,
+ * clear screen + full repaint on first flush or size change, otherwise
+ * only changed rows. Non-tty: plain text lines (no escapes, safe to pipe). */
+static inline void clio_flush(clio_term *t, const clio_buf *b) {
+ if (!b || !b->cells)
+ return;
+ if (!t->is_tty) {
+ clio_flush_plain(b);
+ return;
+ }
+ int full = 1;
+ if (t->has_last && t->last.rows == b->rows && t->last.cols == b->cols)
+ full = 0;
+ if (full) {
+ clio_buf_free(&t->last);
+ if (clio_buf_init(&t->last, b->rows, b->cols) != 0) {
+ t->has_last = 0;
+ return;
+ }
+ t->has_last = 1;
+ esc_fclear_screen(stdout);
+ for (size_t r = 0; r < b->rows; r++) {
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ } else {
+ for (size_t r = 0; r < b->rows; r++) {
+ int same = 1;
+ for (size_t c = 0; c < b->cols; c++) {
+ if (!clio_cell_eq(&t->last.cells[r * b->cols + c],
+ &b->cells[r * b->cols + c])) {
+ same = 0;
+ break;
+ }
+ }
+ if (same)
+ continue;
+ esc_fcursor_position(stdout, 1, (u32)(r + 1));
+ clio_emit_row(stdout, b, r);
+ }
+ }
+ memcpy(t->last.cells, b->cells, b->rows * b->cols * sizeof(clio_cell));
+ fflush(stdout);
+}
+
+/* Move the visible cursor (0-based row/col, clamped to the terminal). */
+static inline void clio_cursor(clio_term *t, size_t row, size_t col) {
+ if (!t->is_tty)
+ return;
+ if (row > t->rows)
+ row = t->rows;
+ if (col > t->cols)
+ col = t->cols;
+ esc_fcursor_position(stdout, (u32)(col + 1), (u32)(row + 1));
+ fflush(stdout);
+}
+
+/* ------------------------------------------------------------------ */
+/* Key input */
+/* ------------------------------------------------------------------ */
+
+/* Key source: 1 + *k set on success, 0 on timeout/EOF. */
+typedef int (*clio_next_key)(void *ud, clio_key *k, int timeout_ms);
+
+static inline int clio_read_key(clio_term *t, clio_key *out, int timeout_ms) {
+ for (;;) {
+ if (t->qt != t->qh) {
+ *out = t->q[t->qh];
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ return 1;
+ }
+ if (timeout_ms == 0)
+ return clio_keydec_flush(&t->dec, out);
+ struct pollfd pfd;
+ pfd.fd = STDIN_FILENO;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ int pr = poll(&pfd, 1, timeout_ms);
+ if (pr <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ unsigned char in[16];
+ ssize_t n = read(STDIN_FILENO, in, sizeof in);
+ if (n <= 0)
+ return clio_keydec_flush(&t->dec, out);
+ clio_key tmp[16];
+ size_t got = clio_keydec_feed(&t->dec, in, (size_t) n, tmp, 16);
+ for (size_t i = 0; i < got; i++) {
+ if (t->qt == t->qh)
+ return clio_keydec_flush(&t->dec, out);
+ size_t slot = t->qt;
+ if (slot == CLIO_KEYQ)
+ slot = 0;
+ if (slot == t->qh) {
+ t->qh++;
+ if (t->qh == CLIO_KEYQ)
+ t->qh = 0;
+ } /* queue full: drop the oldest */
+ t->q[slot] = tmp[i];
+ t->qt = (slot + 1 == CLIO_KEYQ) ? 0 : slot + 1;
+ }
+ }
+}
+
+static inline int clio_next_key_stdin(void *ud, clio_key *k, int timeout_ms) {
+ clio_term *t = (clio_term *) ud;
+ return clio_read_key(t, k, timeout_ms);
+}
+
+/* ------------------------------------------------------------------ */
+/* Single-line editor */
+/* ------------------------------------------------------------------ */
+
+static inline void *clio_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "clio: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+typedef struct clio_str {
+ char *s;
+ size_t len;
+ size_t cap;
+} clio_str;
+
+static inline void clio_str_init(clio_str *x) {
+ x->s = NULL;
+ x->len = 0;
+ x->cap = 0;
+}
+
+static inline void clio_str_free(clio_str *x) {
+ free(x->s);
+ x->s = NULL;
+ x->len = x->cap = 0;
+}
+
+static inline void clio_str_ins(clio_str *x, size_t at, const char *s, size_t n) {
+ if (x->len + n + 1 > x->cap) {
+ size_t nc = x->cap ? x->cap : 16;
+ while (x->len + n + 1 > nc)
+ nc *= 2;
+ x->s = (char *) clio_xrealloc(x->s, nc);
+ x->cap = nc;
+ }
+ memmove(x->s + at + n, x->s + at, x->len - at);
+ memcpy(x->s + at, s, n);
+ x->len += n;
+ x->s[x->len] = '\0';
+}
+
+static inline void clio_str_del(clio_str *x, size_t at, size_t n) {
+ if (at > x->len)
+ at = x->len;
+ if (at + n > x->len)
+ n = x->len - at;
+ memmove(x->s + at, x->s + at + n, x->len - at - n);
+ x->len -= n;
+ x->s[x->len] = '\0';
+}
+
+/* Count visible (non-zero-width) code points in the first n bytes. */
+static inline size_t clio_visible_n(const char *s, size_t n) {
+ size_t c = 0;
+ const char *p = s;
+ const char *end = s + n;
+ while (p < end) {
+ u32 cp;
+ size_t len = clio_utf8_next_n(p, end, &cp);
+ if (cp != 0 && clio_cp_width(cp) > 0)
+ c++;
+ p += len;
+ }
+ return c;
+}
+
+/* Paint the prompt + text (echo, or one '*' per visible code point) into
+ * the canvas at row; returns the caret column.
+ *
+ * The input is ONE visual line: it never wraps. When the caret passes the
+ * right edge the window slides (bash/readline style) so the caret always
+ * stays visible, so backspace/left-arrow work across the "wrap". */
+static inline size_t clio_readline_paint(clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ const clio_str *txt, size_t caret,
+ clio_style tstyle, int echo) {
+ clio_buf_clear_region(b, row, 0, row, b->cols - 1);
+ size_t pw = prompt ? clio_text_width(prompt) : 0;
+ if (prompt && prompt[0])
+ clio_buf_put(b, row, 0, prompt, pstyle, 0);
+ size_t area = (pw < b->cols) ? b->cols - pw : 0;
+ if (txt->len == 0 || area == 0)
+ return pw;
+ if (echo) {
+ size_t cw = clio_text_width_n(txt->s, caret);
+ /* Window left edge (display cells): 0 unless the caret does not
+ * fit, in which case pin the caret to the last column. */
+ size_t L = (cw + 1 > area) ? cw - (area - 1) : 0;
+ /* Largest char boundary <= L (a wide char straddling L is kept
+ * whole, window starts at it). */
+ size_t off = 0, start = 0;
+ const char *p = txt->s;
+ const char *end = txt->s + txt->len;
+ while (p < end) {
+ u32 cp;
+ size_t n = clio_utf8_next_n(p, end, &cp);
+ size_t w = (cp != 0) ? clio_cp_width(cp) : 0;
+ if (off + w > L) {
+ start = (size_t)(p - txt->s); /* `off` is the last boundary <= L */
+ break;
+ }
+ off += w;
+ p += n;
+ }
+ clio_buf_put(b, row, pw, txt->s + start, tstyle, 0);
+ size_t c = pw + (cw - off);
+ if (c >= b->cols) /* wide char straddled the left edge */
+ c = b->cols - 1;
+ return c;
+ }
+ size_t total = clio_visible_n(txt->s, txt->len);
+ size_t cw = clio_visible_n(txt->s, caret);
+ size_t first = (cw + 1 > area) ? cw - (area - 1) : 0;
+ for (size_t i = first; i < total; i++)
+ clio_buf_put(b, row, pw + (i - first), "*", tstyle, 0);
+ return pw + (cw - first);
+}
+
+/* Single-line editor. Renders into ONE canvas row (no wrapping; long
+ * input scrolls to keep the caret visible); reads keys from nk (NULL
+ * = clio_next_key_stdin with ud = t). Editing: printable insert, Tab,
+ * backspace/delete, left/right/home/end, Ctrl+A/E/U/K/W, Ctrl+C and ESC
+ * cancel (NULL), Ctrl+D deletes (EOF/submit when empty), Ctrl+L repaints,
+ * Enter submits. Returns the input (caller frees) or NULL. */
+static inline char *clio_readline(clio_term *t, clio_buf *b, size_t row,
+ const char *prompt, clio_style pstyle,
+ clio_style tstyle, int echo,
+ clio_next_key nk, void *ud, int timeout_ms) {
+ if (!nk) {
+ nk = clio_next_key_stdin;
+ ud = t;
+ }
+ clio_str txt;
+ clio_str_init(&txt);
+ size_t caret = 0;
+
+ size_t col =
+ clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+
+ int quit = 0;
+ for (;;) {
+ clio_key k;
+ if (!nk(ud, &k, timeout_ms))
+ break; /* timeout / EOF / no key */
+ int done = 0;
+ switch (k.kind) {
+ case CLIOK_CHAR: {
+ char tmp[8];
+ int e = clio_utf8_enc(k.cp, tmp);
+ if (e > 0) {
+ clio_str_ins(&txt, caret, tmp, (size_t) e);
+ caret += (size_t) e;
+ }
+ break;
+ }
+ case CLIOK_TAB:
+ clio_str_ins(&txt, caret, "\t", 1);
+ caret++;
+ break;
+ case CLIOK_ENTER:
+ done = 1;
+ break;
+ case CLIOK_BACKSPACE:
+ if (caret > 0) {
+ size_t s = clio_char_before(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ }
+ break;
+ case CLIOK_DELETE:
+ if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_LEFT:
+ if (caret > 0)
+ caret = clio_char_before(txt.s, caret);
+ break;
+ case CLIOK_RIGHT:
+ if (caret < txt.len)
+ caret = clio_char_end(txt.s, txt.len, caret);
+ break;
+ case CLIOK_HOME:
+ case CLIOK_CTRL_A:
+ caret = 0;
+ break;
+ case CLIOK_END:
+ case CLIOK_CTRL_E:
+ caret = txt.len;
+ break;
+ case CLIOK_CTRL_U:
+ clio_str_del(&txt, 0, caret);
+ caret = 0;
+ break;
+ case CLIOK_CTRL_K:
+ clio_str_del(&txt, caret, txt.len - caret);
+ break;
+ case CLIOK_CTRL_W: {
+ size_t s = clio_word_back(txt.s, caret);
+ clio_str_del(&txt, s, caret - s);
+ caret = s;
+ break;
+ }
+ case CLIOK_CTRL_C:
+ quit = 1;
+ break;
+ case CLIOK_CTRL_D:
+ if (txt.len == 0)
+ done = 1;
+ else if (caret < txt.len)
+ clio_str_del(&txt, caret,
+ clio_char_end(txt.s, txt.len, caret) - caret);
+ break;
+ case CLIOK_CTRL_L:
+ if (t->is_tty) {
+ esc_fclear_screen(stdout);
+ esc_fcursor_position(stdout, 1, 1);
+ fflush(stdout);
+ }
+ break;
+ case CLIOK_ESC:
+ quit = 1;
+ break;
+ default:
+ break;
+ }
+ col = clio_readline_paint(b, row, prompt, pstyle, &txt, caret, tstyle, echo);
+ if (t->is_tty)
+ clio_cursor(t, row, col);
+ if (done || quit)
+ break;
+ }
+
+ if (quit || txt.len == 0) {
+ clio_str_free(&txt);
+ return NULL; /* cancelled, or submitted empty */
+ }
+ return txt.s; /* caller frees */
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CLIO_H */
diff --git a/include/dotagents.h b/include/dotagents.h
new file mode 100644
index 0000000..659e156
--- /dev/null
+++ b/include/dotagents.h
@@ -0,0 +1,1282 @@
+/* dotagents.h — portable, header-only implementation of the .agents protocol.
+ *
+ * The .agents protocol (dotagentsprotocol.com) is an open directory
+ * convention for AI agent configuration: one `.agents/` directory holds
+ * MCP server config, agent guidelines (AGENTS.md-compatible), skills,
+ * sub-agent profiles, repeat tasks, persistent memories, and model presets —
+ * in plain, version-controllable files.
+ *
+ * This library is the read-only side: it parses and resolves two layers —
+ * global `~/.agents/` and workspace `./.agents/` — with workspace winning on
+ * conflicts. It does not write.
+ *
+ * Directory schema (per layer):
+ * agents.md instructions (AGENTS.md-compatible)
+ * system-prompt.md system prompt
+ * mcp.json MCP server configuration
+ * models.json model presets & provider keys
+ * speakmcp-settings.json general settings
+ * layouts/ui.json UI/layout prefs
+ * skills/<id>/skill.md skill definition
+ * agents/<id>/agent.md sub-agent profile (+ optional config.json)
+ * tasks/<id>/task.md repeat task definition
+ * memories/ persistent memory entries (markdown files)
+ * .backups/ ignored (write-side artifact)
+ *
+ * Merge rules (final = global + workspace, workspace wins):
+ * - agents.md / system-prompt.md: whole-file replacement (workspace wins)
+ * - models.json: `default` replaced; `presets` merged by preset name
+ * (workspace wins per name)
+ * - mcp.json: `mcpServers` merged by server name (workspace wins per name)
+ * - speakmcp-settings.json / layouts/ui.json: shallow-merged by top-level
+ * key (workspace wins per key)
+ * - skills / agents / tasks / memories: merged by id (workspace wins per id)
+ * - unknown files and malformed files: warned about, skipped (never fatal)
+ *
+ * Conformance (read-only):
+ * [x] agents.md, system-prompt.md, mcp.json, models.json
+ * [x] speakmcp-settings.json, layouts/ui.json
+ * [x] skills/, agents/ (+ config.json), tasks/, memories/
+ * [x] frontmatter: quoted values, CSV + JSON-array lists, bool, int
+ * [x] task `profileId`, agent `connection-type`
+ * [x] case-insensitive file discovery (AGENTS.MD, SKILL.md, ...)
+ * [x] deterministic id-sorted output for stable diffs
+ * [ ] write side (atomic writes, .backups/ rotation) — out of scope
+ * [ ] .dotagents bundles / Hub — out of scope
+ *
+ * Usage: #include "dotagents.h" in your code. Requires frontmatter.h in the
+ * same directory (or on the include path). Zero dependencies beyond
+ * frontmatter.h and libc. All functions are static inline; safe to include
+ * in any number of translation units.
+ *
+ * API:
+ * dotagents_load(out) load ~/.agents + ./.agents (cwd)
+ * dotagents_load_from(out, ws, global) load two explicit layer dirs
+ * dotagents_free(out) free everything
+ * dotagents_project_sections(d, cap) guidelines + skill index + memory note
+ * dotagents_compose_preamble(d, base, cap) base + sections
+ * dotagents_find_skill(d, id) skill or NULL
+ * dotagents_enabled_sub_agent(d, id) enabled sub-agent or NULL
+ * dotagents_mcp_config(d, name) raw per-server JSON or NULL
+ * models_file_get(models, name) preset or NULL
+ * models_file_free(models) free a models_file
+ * memory_importance_rank(m) 0 high, 1 medium, 2 else
+ * parse_frontmatter / doc_get / doc_free / fm_list_items ...
+ * from frontmatter.h (re-exported)
+ *
+ * Memory: all strings are owned by the `dotagents` struct until
+ * dotagents_free. Load never fails: missing layers and malformed files are
+ * recorded in `out->warnings` (or silently skipped when absent).
+ */
+#ifndef DOTAGENTS_H
+#define DOTAGENTS_H
+
+#include "frontmatter.h"
+
+#include <dirent.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Types */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LAYER_GLOBAL,
+ LAYER_WORKSPACE,
+} layer;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ bool enabled;
+ char *body;
+ layer layer;
+} skill;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *description;
+ char *role;
+ char *connection_type; /* `connection-type` frontmatter, or NULL */
+ bool enabled;
+ char *body;
+ char *config; /* raw JSON text, or NULL */
+ layer layer;
+} sub_agent;
+
+typedef struct {
+ char *id;
+ char *name;
+ char *profile_id; /* `profileId` frontmatter, or NULL */
+ unsigned long interval_minutes;
+ bool has_interval;
+ bool enabled;
+ bool run_on_startup;
+ char *body;
+ layer layer;
+} task_entry;
+
+typedef struct {
+ char *id;
+ char *title;
+ char *importance;
+ char **tags;
+ size_t tag_count;
+ char *summary;
+ char *body;
+ layer layer;
+} memory;
+
+typedef struct {
+ char *name;
+ char *provider;
+ char *model;
+ char *base_url;
+ char *api_key;
+ unsigned long max_turns;
+ bool has_max_turns;
+ unsigned long context_limit;
+ bool has_context_limit;
+} model_preset;
+
+typedef struct {
+ char *default_name;
+ model_preset *presets;
+ size_t preset_count;
+} models_file;
+
+typedef struct {
+ layer *found_layers;
+ char **found_paths;
+ size_t found_count;
+ fm_strlist warnings;
+ char *system_prompt;
+ char *agents_md;
+ char *settings_json; /* merged speakmcp-settings.json, or NULL */
+ char *layouts_json; /* merged layouts/ui.json, or NULL */
+ fm_json *settings; /* accumulated merge tree (transient) */
+ fm_json *layouts; /* accumulated merge tree (transient) */
+ models_file models;
+ bool has_models;
+ char **mcp_names;
+ char **mcp_configs; /* raw JSON per server */
+ size_t mcp_count;
+ skill *skills;
+ size_t skill_count;
+ sub_agent *sub_agents;
+ size_t sub_agent_count;
+ task_entry *tasks;
+ size_t task_count;
+ memory *memories;
+ size_t memory_count;
+} dotagents;
+
+/* qsort comparators (replace the old O(n^2) inline sorts) */
+typedef struct { char *name; char *path; } da_dirent;
+static int da_cmp_dirent(const void *a, const void *b) {
+ return strcmp(((const da_dirent *)a)->name, ((const da_dirent *)b)->name);
+}
+static int da_cmp_skill_id(const void *a, const void *b) {
+ return strcmp(((const skill *)a)->id, ((const skill *)b)->id);
+}
+static int da_cmp_subagent_id(const void *a, const void *b) {
+ return strcmp(((const sub_agent *)a)->id, ((const sub_agent *)b)->id);
+}
+static int da_cmp_task_id(const void *a, const void *b) {
+ return strcmp(((const task_entry *)a)->id, ((const task_entry *)b)->id);
+}
+static int da_cmp_memory_id(const void *a, const void *b) {
+ return strcmp(((const memory *)a)->id, ((const memory *)b)->id);
+}
+static int da_cmp_cstr(const void *a, const void *b) {
+ return strcmp(*(const char *const *)a, *(const char *const *)b);
+}
+
+/* ------------------------------------------------------------------ */
+/* Layer helpers */
+/* ------------------------------------------------------------------ */
+
+static inline const char *layer_short(layer l) {
+ return l == LAYER_GLOBAL ? "global" : "workspace";
+}
+
+static inline const char *layer_display(layer l) {
+ return l == LAYER_GLOBAL ? "~/.agents" : ".agents";
+}
+
+/* ------------------------------------------------------------------ */
+/* File I/O */
+/* ------------------------------------------------------------------ */
+
+static inline bool da_is_file(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static inline bool da_is_dir(const char *p) {
+ struct stat st;
+ return stat(p, &st) == 0 && S_ISDIR(st.st_mode);
+}
+
+/* Read a whole file. 0 on success, -1 on any failure. */
+static inline int da_read_file(const char *path, char **out, size_t *out_len) {
+ FILE *f = fopen(path, "rb");
+ if (!f)
+ return -1;
+ fm_buf b;
+ fm_buf_init(&b);
+ char chunk[8192];
+ size_t r;
+ while ((r = fread(chunk, 1, sizeof chunk, f)) > 0)
+ fm_buf_putn(&b, chunk, r);
+ int fe = ferror(f);
+ fclose(f);
+ if (fe) {
+ fm_buf_free(&b);
+ return -1;
+ }
+ size_t total = b.len;
+ if (out)
+ *out = fm_buf_steal(&b);
+ if (out_len)
+ *out_len = total;
+ return 0;
+}
+
+static inline bool da_dir_has_entry(const char *dir) {
+ DIR *d = opendir(dir);
+ if (!d)
+ return false;
+ bool has = readdir(d) != NULL;
+ closedir(d);
+ return has;
+}
+
+/* Find `{stem}.md` case-insensitively directly inside dir. */
+static inline char *da_find_md(const char *dir, const char *stem) {
+ char *want = fm_strfmt("%s.md", stem);
+ for (char *p = want; *p; p++)
+ *p = (char)tolower((unsigned char)*p);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(want);
+ return NULL;
+ }
+ char *result = NULL;
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcasecmp(e->d_name, want) == 0) {
+ result = fm_strfmt("%s/%s", dir, e->d_name);
+ break;
+ }
+ }
+ closedir(d);
+ free(want);
+ return result;
+}
+
+/* Sorted subdirectories of `dir`: returns name+path arrays. */
+static inline void da_sub_dirs(const char *dir, char ***out_names,
+ char ***out_paths, size_t *out_count) {
+ *out_count = 0;
+ DIR *d = opendir(dir);
+ if (!d)
+ return;
+ size_t cap = 8, n = 0;
+ char **names = (char **)fm_xmalloc(cap * sizeof(char *));
+ char **paths = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
+ continue;
+ char *path = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_dir(path)) {
+ free(path);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ names = (char **)fm_xrealloc(names, cap * sizeof(char *));
+ paths = (char **)fm_xrealloc(paths, cap * sizeof(char *));
+ }
+ names[n] = fm_strdup(e->d_name);
+ paths[n] = path;
+ n++;
+ }
+ closedir(d);
+ /* sort by name (keep the parallel paths array in lockstep) */
+ if (n > 1) {
+ da_dirent *ents = (da_dirent *)fm_xmalloc(n * sizeof *ents);
+ for (size_t i = 0; i < n; i++) {
+ ents[i].name = names[i];
+ ents[i].path = paths[i];
+ }
+ qsort(ents, n, sizeof *ents, da_cmp_dirent);
+ for (size_t i = 0; i < n; i++) {
+ names[i] = ents[i].name;
+ paths[i] = ents[i].path;
+ }
+ free(ents);
+ }
+ *out_names = names;
+ *out_paths = paths;
+ *out_count = n;
+}
+
+/* Does this root hold any recognizable protocol content? */
+static inline bool da_has_protocol_content(const char *root) {
+ const char *stems[2] = {"agents", "system-prompt"};
+ for (size_t i = 0; i < 2; i++) {
+ char *md = da_find_md(root, stems[i]);
+ if (md) {
+ free(md);
+ return true;
+ }
+ }
+ const char *files[4] = {"models.json", "mcp.json",
+ "speakmcp-settings.json", "layouts/ui.json"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, files[i]);
+ bool has = da_is_file(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ const char *subs[4] = {"skills", "agents", "tasks", "memories"};
+ for (size_t i = 0; i < 4; i++) {
+ char *p = fm_strfmt("%s/%s", root, subs[i]);
+ bool has = da_is_dir(p) && da_dir_has_entry(p);
+ free(p);
+ if (has)
+ return true;
+ }
+ return false;
+}
+
+/* ------------------------------------------------------------------ */
+/* Accumulator helpers */
+/* ------------------------------------------------------------------ */
+
+static inline void da_warn(dotagents *out, const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *msg = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ fm_sl_push(&out->warnings, msg);
+}
+
+static inline char *da_read_optional(const char *path, layer layer,
+ dotagents *out) {
+ char *raw = NULL;
+ size_t len = 0;
+ if (da_read_file(path, &raw, &len) != 0) {
+ struct stat st;
+ if (stat(path, &st) == 0) {
+ /* exists but unreadable */
+ const char *base = strrchr(path, '/');
+ da_warn(out, "%s: %s: read error", layer_display(layer),
+ base ? base + 1 : path);
+ }
+ free(raw);
+ return NULL;
+ }
+ /* whitespace-only: treat as absent */
+ size_t i = 0;
+ while (i < len && (raw[i] == ' ' || raw[i] == '\t' || raw[i] == '\n' ||
+ raw[i] == '\r'))
+ i++;
+ if (i == len) {
+ free(raw);
+ return NULL;
+ }
+ return raw;
+}
+
+/* ------------------------------------------------------------------ */
+/* Ingest */
+/* ------------------------------------------------------------------ */
+
+static inline void da_ingest_models_json(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/models.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ /* unknown field check (deny_unknown_fields) */
+ bool ok = true;
+ for (size_t i = 0; i < o->count; i++) {
+ const char *k = o->items[i] ? o->items[i]->key : NULL;
+ if (!k || (strcmp(k, "default") != 0 && strcmp(k, "presets") != 0)) {
+ ok = false;
+ break;
+ }
+ }
+ if (ok) {
+ const fm_json *def = fm_json_get(o, "default");
+ if (def && def->type == FMJ_STRING) {
+ free(out->models.default_name);
+ out->models.default_name = fm_strdup(def->str);
+ }
+ const fm_json *pres = fm_json_get(o, "presets");
+ if (pres && pres->type == FMJ_OBJECT) {
+ for (size_t pi = 0; pi < pres->count; pi++) {
+ const fm_json *pv = pres->items[pi];
+ if (!pv || pv->type != FMJ_OBJECT) {
+ da_warn(out, "%s: models.json: preset `%s` is not an "
+ "object; skipped",
+ layer_short(layer), pv && pv->key ? pv->key : "?");
+ continue;
+ }
+ const fm_json *bad = NULL;
+ for (size_t fi = 0; fi < pv->count; fi++) {
+ const char *fk = pv->items[fi] ? pv->items[fi]->key : NULL;
+ if (!fk ||
+ (strcmp(fk, "provider") != 0 &&
+ strcmp(fk, "model") != 0 &&
+ strcmp(fk, "base_url") != 0 &&
+ strcmp(fk, "api_key") != 0 &&
+ strcmp(fk, "max_turns") != 0 &&
+ strcmp(fk, "context_limit") != 0)) {
+ bad = pv->items[fi];
+ break;
+ }
+ }
+ if (bad) {
+ da_warn(out, "%s: models.json: preset `%s` has unknown "
+ "field `%s`; skipped",
+ layer_short(layer), pv->key ? pv->key : "?",
+ bad->key ? bad->key : "?");
+ continue;
+ }
+ model_preset mp;
+ memset(&mp, 0, sizeof mp);
+ mp.name = fm_strdup(pv->key ? pv->key : "");
+ const fm_json *s;
+ if ((s = fm_json_get(pv, "provider")) && s->type == FMJ_STRING)
+ mp.provider = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "model")) && s->type == FMJ_STRING)
+ mp.model = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "base_url")) && s->type == FMJ_STRING)
+ mp.base_url = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "api_key")) && s->type == FMJ_STRING)
+ mp.api_key = fm_strdup(s->str);
+ if ((s = fm_json_get(pv, "max_turns")) &&
+ s->type == FMJ_NUMBER) {
+ mp.max_turns = (unsigned long)s->num;
+ mp.has_max_turns = true;
+ }
+ if ((s = fm_json_get(pv, "context_limit")) &&
+ s->type == FMJ_NUMBER) {
+ mp.context_limit = (unsigned long)s->num;
+ mp.has_context_limit = true;
+ }
+ /* replace by name or append */
+ bool replaced = false;
+ for (size_t i = 0; i < out->models.preset_count; i++) {
+ if (strcmp(out->models.presets[i].name, pv->key) == 0) {
+ free(out->models.presets[i].name);
+ free(out->models.presets[i].provider);
+ free(out->models.presets[i].model);
+ free(out->models.presets[i].base_url);
+ free(out->models.presets[i].api_key);
+ out->models.presets[i] = mp;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->models.presets = (model_preset *)fm_xrealloc(
+ out->models.presets,
+ (out->models.preset_count + 1) *
+ sizeof(model_preset));
+ out->models.presets[out->models.preset_count++] = mp;
+ }
+ out->has_models = true;
+ }
+ }
+ } else {
+ da_warn(out, "%s: models.json: invalid JSON; skipped", layer_short(layer));
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_mcp(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/mcp.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o) {
+ da_warn(out, "%s: mcp.json: invalid JSON; skipped", layer_short(layer));
+ return;
+ }
+ const fm_json *servers = fm_json_get(o, "mcpServers");
+ if (!servers || servers->type != FMJ_OBJECT) {
+ da_warn(out, "%s: mcp.json: missing `mcpServers` object; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ for (size_t i = 0; i < servers->count; i++) {
+ const fm_json *sv = servers->items[i];
+ if (!sv)
+ continue;
+ char *cfg = fm_json_serialize(sv);
+ bool replaced = false;
+ for (size_t j = 0; j < out->mcp_count; j++) {
+ if (strcmp(out->mcp_names[j], sv->key ? sv->key : "") == 0) {
+ free(out->mcp_configs[j]);
+ out->mcp_configs[j] = cfg;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->mcp_names =
+ (char **)fm_xrealloc(out->mcp_names,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_configs =
+ (char **)fm_xrealloc(out->mcp_configs,
+ (out->mcp_count + 1) * sizeof(char *));
+ out->mcp_names[out->mcp_count] =
+ fm_strdup(sv->key ? sv->key : "");
+ out->mcp_configs[out->mcp_count] = cfg;
+ out->mcp_count++;
+ }
+ }
+ fm_json_free(o);
+}
+
+static inline void da_ingest_settings(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/speakmcp-settings.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: speakmcp-settings.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->settings = fm_json_merge_owned(out->settings, o);
+}
+
+static inline void da_ingest_layouts(const char *root, layer layer,
+ dotagents *out) {
+ char *path = fm_strfmt("%s/layouts/ui.json", root);
+ char *raw = da_read_optional(path, layer, out);
+ free(path);
+ if (!raw)
+ return;
+ fm_json *o = fm_json_parse(raw);
+ free(raw);
+ if (!o || o->type != FMJ_OBJECT) {
+ da_warn(out, "%s: layouts/ui.json: invalid JSON; skipped",
+ layer_short(layer));
+ fm_json_free(o);
+ return;
+ }
+ out->layouts = fm_json_merge_owned(out->layouts, o);
+}
+
+static inline void da_ingest_skills(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/skills", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "skill");
+ if (!md) {
+ da_warn(out, "%s: skills/%s: missing skill.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: skills/%s: skill.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ skill s;
+ s.id = fm_strdup(id);
+ s.name = fm_strdup(name);
+ s.description = fm_strdup(desc);
+ s.enabled = enabled;
+ s.body = fm_strdup(d.body);
+ s.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->skill_count; j++) {
+ if (strcmp(out->skills[j].id, s.id) == 0) {
+ free(out->skills[j].id);
+ free(out->skills[j].name);
+ free(out->skills[j].description);
+ free(out->skills[j].body);
+ out->skills[j] = s;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->skills = (skill *)fm_xrealloc(
+ out->skills, (out->skill_count + 1) * sizeof(skill));
+ out->skills[out->skill_count++] = s;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ /* keep sorted by id */
+ if (out->skill_count > 1)
+ qsort(out->skills, out->skill_count, sizeof *out->skills,
+ da_cmp_skill_id);
+}
+
+static inline void da_ingest_sub_agents(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/agents", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "agent");
+ if (!md) {
+ da_warn(out, "%s: agents/%s: missing agent.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: agents/%s: agent.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ /* optional config.json next to agent.md */
+ char *config = NULL;
+ char *cp = fm_strfmt("%s/config.json", paths[i]);
+ char *craw = NULL;
+ if (da_read_file(cp, &craw, NULL) == 0) {
+ size_t cl = strlen(craw);
+ size_t ci = 0;
+ while (ci < cl && (craw[ci] == ' ' || craw[ci] == '\t' ||
+ craw[ci] == '\n' || craw[ci] == '\r'))
+ ci++;
+ if (ci < cl) {
+ fm_json *cv = fm_json_parse(craw);
+ if (cv) {
+ config = fm_json_serialize(cv);
+ fm_json_free(cv);
+ } else {
+ da_warn(out, "%s: agents/%s/config.json: invalid JSON; "
+ "skipped",
+ layer_short(layer), names[i]);
+ }
+ }
+ free(craw);
+ }
+ free(cp);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *desc = "";
+ if ((v = doc_get(&d, "description")) && v->kind == FM_STR)
+ desc = v->s;
+ char *role = "delegation-target";
+ if ((v = doc_get(&d, "role")) && v->kind == FM_STR)
+ role = v->s;
+ char *ctype = NULL;
+ if ((v = doc_get(&d, "connection-type")) && v->kind == FM_STR)
+ ctype = v->s;
+ bool enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ enabled = v->b;
+
+ sub_agent a;
+ a.id = fm_strdup(id);
+ a.name = fm_strdup(name);
+ a.description = fm_strdup(desc);
+ a.role = fm_strdup(role);
+ a.connection_type = ctype ? fm_strdup(ctype) : NULL;
+ a.enabled = enabled;
+ a.body = fm_strdup(d.body);
+ a.config = config;
+ a.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->sub_agent_count; j++) {
+ if (strcmp(out->sub_agents[j].id, a.id) == 0) {
+ free(out->sub_agents[j].id);
+ free(out->sub_agents[j].name);
+ free(out->sub_agents[j].description);
+ free(out->sub_agents[j].role);
+ free(out->sub_agents[j].connection_type);
+ free(out->sub_agents[j].body);
+ free(out->sub_agents[j].config);
+ out->sub_agents[j] = a;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->sub_agents =
+ (sub_agent *)fm_xrealloc(out->sub_agents,
+ (out->sub_agent_count + 1) *
+ sizeof(sub_agent));
+ out->sub_agents[out->sub_agent_count++] = a;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->sub_agent_count > 1)
+ qsort(out->sub_agents, out->sub_agent_count, sizeof *out->sub_agents,
+ da_cmp_subagent_id);
+}
+
+static inline void da_ingest_tasks(const char *root, layer layer,
+ dotagents *out) {
+ char **names = NULL, **paths = NULL;
+ size_t n = 0;
+ char *base = fm_strfmt("%s/tasks", root);
+ da_sub_dirs(base, &names, &paths, &n);
+ free(base);
+ for (size_t i = 0; i < n; i++) {
+ char *md = da_find_md(paths[i], "task");
+ if (!md) {
+ da_warn(out, "%s: tasks/%s: missing task.md; skipped",
+ layer_short(layer), names[i]);
+ continue;
+ }
+ char *text = NULL;
+ if (da_read_file(md, &text, NULL) != 0) {
+ da_warn(out, "%s: tasks/%s: task.md unreadable; skipped",
+ layer_short(layer), names[i]);
+ free(md);
+ continue;
+ }
+ doc d;
+ parse_frontmatter(text, &d);
+ free(text);
+
+ char *id = names[i];
+ const fm_value *v;
+ if ((v = doc_get(&d, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *name = names[i];
+ if ((v = doc_get(&d, "name")) && v->kind == FM_STR)
+ name = v->s;
+ char *pid = NULL;
+ if ((v = doc_get(&d, "profileId")) && v->kind == FM_STR && v->s &&
+ *v->s)
+ pid = v->s;
+
+ task_entry t;
+ memset(&t, 0, sizeof t);
+ t.id = fm_strdup(id);
+ t.name = fm_strdup(name);
+ t.profile_id = pid ? fm_strdup(pid) : NULL;
+ if ((v = doc_get(&d, "intervalMinutes")) && v->kind == FM_INT &&
+ v->i >= 0) {
+ t.interval_minutes = (unsigned long)v->i;
+ t.has_interval = true;
+ }
+ t.enabled = true;
+ if ((v = doc_get(&d, "enabled")) && v->kind == FM_BOOL)
+ t.enabled = v->b;
+ t.run_on_startup = false;
+ if ((v = doc_get(&d, "runOnStartup")) && v->kind == FM_BOOL)
+ t.run_on_startup = v->b;
+ t.body = fm_strdup(d.body);
+ t.layer = layer;
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->task_count; j++) {
+ if (strcmp(out->tasks[j].id, t.id) == 0) {
+ free(out->tasks[j].id);
+ free(out->tasks[j].name);
+ free(out->tasks[j].profile_id);
+ free(out->tasks[j].body);
+ out->tasks[j] = t;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->tasks =
+ (task_entry *)fm_xrealloc(out->tasks,
+ (out->task_count + 1) *
+ sizeof(task_entry));
+ out->tasks[out->task_count++] = t;
+ }
+ doc_free(&d);
+ free(md);
+ }
+ for (size_t i = 0; i < n; i++) {
+ free(names[i]);
+ free(paths[i]);
+ }
+ free(names);
+ free(paths);
+ if (out->task_count > 1)
+ qsort(out->tasks, out->task_count, sizeof *out->tasks,
+ da_cmp_task_id);
+}
+
+static inline void da_ingest_memories(const char *root, layer layer,
+ dotagents *out) {
+ char *dir = fm_strfmt("%s/memories", root);
+ DIR *d = opendir(dir);
+ if (!d) {
+ free(dir);
+ return;
+ }
+ /* collect .md files */
+ size_t cap = 8, n = 0;
+ char **files = (char **)fm_xmalloc(cap * sizeof(char *));
+ struct dirent *e;
+ while ((e = readdir(d))) {
+ char *p = fm_strfmt("%s/%s", dir, e->d_name);
+ if (!da_is_file(p)) {
+ free(p);
+ continue;
+ }
+ const char *dot = strrchr(e->d_name, '.');
+ if (!dot || strcasecmp(dot, ".md") != 0) {
+ free(p);
+ continue;
+ }
+ if (n + 1 > cap) {
+ cap *= 2;
+ files = (char **)fm_xrealloc(files, cap * sizeof(char *));
+ }
+ files[n++] = p;
+ }
+ closedir(d);
+ if (n > 1)
+ qsort(files, n, sizeof *files, da_cmp_cstr);
+ for (size_t i = 0; i < n; i++) {
+ const char *base = strrchr(files[i], '/');
+ base = base ? base + 1 : files[i];
+ const char *dot = strrchr(base, '.');
+ char *stem = fm_strndup(
+ base, dot ? (size_t)(dot - base) : strlen(base));
+ char *text = NULL;
+ if (da_read_file(files[i], &text, NULL) != 0) {
+ da_warn(out, "%s: memories/%s.md: unreadable; skipped",
+ layer_short(layer), stem);
+ free(stem);
+ continue;
+ }
+ doc d2;
+ parse_frontmatter(text, &d2);
+ free(text);
+
+ char *id = stem;
+ const fm_value *v;
+ if ((v = doc_get(&d2, "id")) && v->kind == FM_STR && v->s && *v->s)
+ id = v->s;
+ char *title = stem;
+ if ((v = doc_get(&d2, "title")) && v->kind == FM_STR)
+ title = v->s;
+ char *importance = "medium";
+ if ((v = doc_get(&d2, "importance")) && v->kind == FM_STR)
+ importance = v->s;
+ char *summary = "";
+ if ((v = doc_get(&d2, "content")) && v->kind == FM_STR)
+ summary = v->s;
+
+ memory m;
+ memset(&m, 0, sizeof m);
+ m.id = fm_strdup(id);
+ m.title = fm_strdup(title);
+ m.importance = fm_strdup(importance);
+ m.summary = fm_strdup(summary);
+ m.body = fm_strdup(d2.body);
+ m.layer = layer;
+ if ((v = doc_get(&d2, "tags"))) {
+ fm_strlist items;
+ fm_list_items(v, &items);
+ m.tags = (char **)fm_xmalloc(
+ (items.len ? items.len : 1) * sizeof(char *));
+ for (size_t k = 0; k < items.len; k++)
+ m.tags[k] = fm_strdup(items.items[k]);
+ m.tag_count = items.len;
+ fm_sl_free(&items);
+ }
+
+ bool replaced = false;
+ for (size_t j = 0; j < out->memory_count; j++) {
+ if (strcmp(out->memories[j].id, m.id) == 0) {
+ free(out->memories[j].id);
+ free(out->memories[j].title);
+ free(out->memories[j].importance);
+ free(out->memories[j].summary);
+ free(out->memories[j].body);
+ for (size_t k = 0; k < out->memories[j].tag_count; k++)
+ free(out->memories[j].tags[k]);
+ free(out->memories[j].tags);
+ out->memories[j] = m;
+ replaced = true;
+ break;
+ }
+ }
+ if (!replaced) {
+ out->memories =
+ (memory *)fm_xrealloc(out->memories,
+ (out->memory_count + 1) *
+ sizeof(memory));
+ out->memories[out->memory_count++] = m;
+ }
+ doc_free(&d2);
+ free(stem);
+ }
+ for (size_t i = 0; i < n; i++)
+ free(files[i]);
+ free(files);
+ free(dir);
+ if (out->memory_count > 1)
+ qsort(out->memories, out->memory_count, sizeof *out->memories,
+ da_cmp_memory_id);
+}
+
+static inline void da_ingest(const char *root, layer layer, dotagents *out) {
+ char *agents_md = da_find_md(root, "agents");
+ if (agents_md) {
+ char *text = da_read_optional(agents_md, layer, out);
+ if (text) {
+ free(out->agents_md);
+ out->agents_md = text;
+ }
+ free(agents_md);
+ }
+ char *sp = da_find_md(root, "system-prompt");
+ if (sp) {
+ char *text = da_read_optional(sp, layer, out);
+ if (text) {
+ free(out->system_prompt);
+ out->system_prompt = text;
+ }
+ free(sp);
+ }
+ da_ingest_models_json(root, layer, out);
+ da_ingest_mcp(root, layer, out);
+ da_ingest_settings(root, layer, out);
+ da_ingest_layouts(root, layer, out);
+ da_ingest_skills(root, layer, out);
+ da_ingest_sub_agents(root, layer, out);
+ da_ingest_tasks(root, layer, out);
+ da_ingest_memories(root, layer, out);
+}
+
+/* ------------------------------------------------------------------ */
+/* Public API */
+/* ------------------------------------------------------------------ */
+
+static inline void dotagents_free(dotagents *d) {
+ free(d->found_layers);
+ for (size_t i = 0; i < d->found_count; i++)
+ free(d->found_paths[i]);
+ free(d->found_paths);
+ fm_sl_free(&d->warnings);
+ free(d->system_prompt);
+ free(d->agents_md);
+ free(d->settings_json);
+ free(d->layouts_json);
+ fm_json_free(d->settings);
+ fm_json_free(d->layouts);
+ free(d->models.default_name);
+ for (size_t i = 0; i < d->models.preset_count; i++) {
+ free(d->models.presets[i].name);
+ free(d->models.presets[i].provider);
+ free(d->models.presets[i].model);
+ free(d->models.presets[i].base_url);
+ free(d->models.presets[i].api_key);
+ }
+ free(d->models.presets);
+ for (size_t i = 0; i < d->mcp_count; i++) {
+ free(d->mcp_names[i]);
+ free(d->mcp_configs[i]);
+ }
+ free(d->mcp_names);
+ free(d->mcp_configs);
+ for (size_t i = 0; i < d->skill_count; i++) {
+ free(d->skills[i].id);
+ free(d->skills[i].name);
+ free(d->skills[i].description);
+ free(d->skills[i].body);
+ }
+ free(d->skills);
+ for (size_t i = 0; i < d->sub_agent_count; i++) {
+ free(d->sub_agents[i].id);
+ free(d->sub_agents[i].name);
+ free(d->sub_agents[i].description);
+ free(d->sub_agents[i].role);
+ free(d->sub_agents[i].connection_type);
+ free(d->sub_agents[i].body);
+ free(d->sub_agents[i].config);
+ }
+ free(d->sub_agents);
+ for (size_t i = 0; i < d->task_count; i++) {
+ free(d->tasks[i].id);
+ free(d->tasks[i].name);
+ free(d->tasks[i].profile_id);
+ free(d->tasks[i].body);
+ }
+ free(d->tasks);
+ for (size_t i = 0; i < d->memory_count; i++) {
+ free(d->memories[i].id);
+ free(d->memories[i].title);
+ free(d->memories[i].importance);
+ for (size_t k = 0; k < d->memories[i].tag_count; k++)
+ free(d->memories[i].tags[k]);
+ free(d->memories[i].tags);
+ free(d->memories[i].summary);
+ free(d->memories[i].body);
+ }
+ free(d->memories);
+}
+
+static inline void dotagents_load_from(dotagents *out, const char *workspace,
+ const char *global) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ if (global && da_is_dir(global) && da_has_protocol_content(global)) {
+ da_ingest(global, LAYER_GLOBAL, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_GLOBAL;
+ out->found_paths[out->found_count] = fm_strdup(global);
+ out->found_count++;
+ }
+ if (da_is_dir(workspace) && da_has_protocol_content(workspace)) {
+ da_ingest(workspace, LAYER_WORKSPACE, out);
+ out->found_layers = (layer *)fm_xrealloc(
+ out->found_layers, (out->found_count + 1) * sizeof(layer));
+ out->found_paths = (char **)fm_xrealloc(
+ out->found_paths, (out->found_count + 1) * sizeof(char *));
+ out->found_layers[out->found_count] = LAYER_WORKSPACE;
+ out->found_paths[out->found_count] = fm_strdup(workspace);
+ out->found_count++;
+ }
+ /* materialize the transient merge trees as stable JSON text */
+ if (out->settings) {
+ out->settings_json = fm_json_serialize(out->settings);
+ fm_json_free(out->settings);
+ out->settings = NULL;
+ }
+ if (out->layouts) {
+ out->layouts_json = fm_json_serialize(out->layouts);
+ fm_json_free(out->layouts);
+ out->layouts = NULL;
+ }
+}
+
+static inline void dotagents_load(dotagents *out) {
+ memset(out, 0, sizeof *out);
+ fm_sl_init(&out->warnings);
+ const char *home = getenv("HOME");
+ char *global = NULL;
+ if (home)
+ global = fm_strfmt("%s/.agents", home);
+ dotagents_load_from(out, ".agents", global);
+ free(global);
+}
+
+/* Sections appended to the preamble: guidelines + skill index + memories. */
+static inline char *dotagents_project_sections(const dotagents *d,
+ unsigned long cap) {
+ if (cap < 1)
+ cap = 1;
+ fm_buf out;
+ fm_buf_init(&out);
+ if (d->agents_md && d->agents_md[0] &&
+ d->agents_md[strcspn(d->agents_md, " \t\n\r")] != 0) {
+ fm_buf_push(&out, '\n');
+ /* trim end */
+ size_t l = strlen(d->agents_md);
+ while (l && (d->agents_md[l - 1] == '\n' || d->agents_md[l - 1] == '\r' ||
+ d->agents_md[l - 1] == ' '))
+ l--;
+ fm_buf_putn(&out, d->agents_md, l);
+ fm_buf_push(&out, '\n');
+ }
+ /* enabled skills */
+ size_t enabled_count = 0;
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (d->skills[i].enabled)
+ enabled_count++;
+ if (enabled_count > 0) {
+ fm_buf_put(&out, "\n# Skills\n\n");
+ fm_buf_put(
+ &out,
+ "Skills available for this project. Load a skill's full "
+ "instructions with the `use_skill` tool (pass its id):\n");
+ size_t listed = 0;
+ for (size_t i = 0; i < d->skill_count && listed < cap; i++) {
+ if (!d->skills[i].enabled)
+ continue;
+ if (d->skills[i].description[0])
+ fm_buf_fmt(&out, "- %s: %s\n", d->skills[i].id,
+ d->skills[i].description);
+ else
+ fm_buf_fmt(&out, "- %s\n", d->skills[i].id);
+ listed++;
+ }
+ if (enabled_count > cap)
+ fm_buf_fmt(&out, "…and %lu more\n", enabled_count - cap);
+ }
+ if (d->memory_count > 0) {
+ fm_buf_fmt(
+ &out,
+ "\n# Memories\n\n%lu persistent memory entries are available "
+ "across sessions. Retrieve them with the `search_memories` tool "
+ "(keyword or tag query).\n",
+ d->memory_count);
+ }
+ return fm_buf_steal(&out);
+}
+
+static inline char *dotagents_compose_preamble(const dotagents *d,
+ const char *base,
+ unsigned long cap) {
+ char *sections = dotagents_project_sections(d, cap);
+ fm_buf b;
+ fm_buf_init(&b);
+ fm_buf_put(&b, base);
+ fm_buf_put(&b, sections);
+ free(sections);
+ return fm_buf_steal(&b);
+}
+
+static inline const sub_agent *dotagents_enabled_sub_agent(
+ const dotagents *d, const char *id) {
+ for (size_t i = 0; i < d->sub_agent_count; i++)
+ if (strcmp(d->sub_agents[i].id, id) == 0 && d->sub_agents[i].enabled)
+ return &d->sub_agents[i];
+ return NULL;
+}
+
+static inline const skill *dotagents_find_skill(const dotagents *d,
+ const char *id) {
+ for (size_t i = 0; i < d->skill_count; i++)
+ if (strcmp(d->skills[i].id, id) == 0)
+ return &d->skills[i];
+ return NULL;
+}
+
+static inline const char *dotagents_mcp_config(const dotagents *d,
+ const char *name) {
+ for (size_t i = 0; i < d->mcp_count; i++)
+ if (strcmp(d->mcp_names[i], name) == 0)
+ return d->mcp_configs[i];
+ return NULL;
+}
+
+static inline const model_preset *models_file_get(const models_file *m,
+ const char *name) {
+ for (size_t i = 0; i < m->preset_count; i++)
+ if (strcmp(m->presets[i].name, name) == 0)
+ return &m->presets[i];
+ return NULL;
+}
+
+static inline void models_file_free(models_file *m) {
+ free(m->default_name);
+ for (size_t i = 0; i < m->preset_count; i++) {
+ free(m->presets[i].name);
+ free(m->presets[i].provider);
+ free(m->presets[i].model);
+ free(m->presets[i].base_url);
+ free(m->presets[i].api_key);
+ }
+ free(m->presets);
+ m->presets = NULL;
+ m->preset_count = 0;
+}
+
+static inline int memory_importance_rank(const memory *m) {
+ fm_buf l;
+ fm_buf_init(&l);
+ for (const char *p = m->importance; *p; p++)
+ fm_buf_push(&l, (char)tolower((unsigned char)*p));
+ l.data[l.len] = '\0';
+ int r = 2;
+ if (strcmp(l.data, "high") == 0)
+ r = 0;
+ else if (strcmp(l.data, "medium") == 0 || strcmp(l.data, "normal") == 0)
+ r = 1;
+ fm_buf_free(&l);
+ return r;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* DOTAGENTS_H */
diff --git a/include/escape.h b/include/escape.h
index db46694..af616b3 100755
--- a/include/escape.h
+++ b/include/escape.h
@@ -37,10 +37,16 @@ void esc_fcursor_right(FILE *, u32 n);
void esc_cursor_line_next(u32 n);
void esc_fcursor_line_next(FILE *, u32 n);
+#define ESC_CURSOR_PREVIOUS(n) CSI #n "F"
+void esc_cursor_previous(u32 n);
+void esc_fcursor_previous(FILE *, u32 n);
+
#define ESC_CURSOR_POSITION(col, row) CSI #row ";" #col "H"
void esc_cursor_position(u32 col, u32 row);
void esc_fcursor_position(FILE *, u32 col, u32 row);
+#define ESC_CURSOR_COLUMN_HOME CSI "G"
+
#define ESC_CLEAR_SCREEN CSI "2J"
void esc_clear_screen(void);
void esc_fclear_screen(FILE *);
@@ -77,6 +83,8 @@ void esc_fscroll_down(FILE *, u32 n);
#define ESC_RESET CSI "0" ESC_SGR
#define ESC_BOLD CSI "1" ESC_SGR
+#define ESC_DIM CSI "2" ESC_SGR
+#define ESC_ITALIC CSI "3" ESC_SGR
#define ESC_UNDERLINE CSI "4" ESC_SGR
#define ESC_BLINK CSI "5" ESC_SGR
#define ESC_REVERSE CSI "7" ESC_SGR
@@ -106,6 +114,60 @@ void esc_fcolor_bg_256(FILE *, u32 n);
#define ESC_COLOR_FG_BRIGHT(n) CSI "9" #n ESC_SGR
#define ESC_COLOR_BG_BRIGHT(n) CSI "10" #n ESC_SGR
+/* Named standard colors (ANSI 0-7). */
+#define ESC_COLOR_BLACK ESC_COLOR_FG(0)
+#define ESC_COLOR_RED ESC_COLOR_FG(1)
+#define ESC_COLOR_GREEN ESC_COLOR_FG(2)
+#define ESC_COLOR_YELLOW ESC_COLOR_FG(3)
+#define ESC_COLOR_BLUE ESC_COLOR_FG(4)
+#define ESC_COLOR_MAGENTA ESC_COLOR_FG(5)
+#define ESC_COLOR_CYAN ESC_COLOR_FG(6)
+#define ESC_COLOR_WHITE ESC_COLOR_FG(7)
+
+#define ESC_CURSOR_HIDE CSI "?25l"
+void esc_cursor_hide(void);
+void esc_fcursor_hide(FILE *);
+
+#define ESC_CURSOR_SHOW CSI "?25h"
+void esc_cursor_show(void);
+void esc_fcursor_show(FILE *);
+
+#define ESC_CURSOR_SAVE CSI "s"
+void esc_cursor_save(void);
+void esc_fcursor_save(FILE *);
+
+#define ESC_CURSOR_RESTORE CSI "u"
+void esc_cursor_restore(void);
+void esc_fcursor_restore(FILE *);
+
+#define ESC_BELL "\x07"
+void esc_bell(void);
+void esc_fbell(FILE *);
+
+#define ESC_ALT_SCREEN_ON CSI "?1049h"
+void esc_alt_screen_on(void);
+void esc_falt_screen_on(FILE *);
+
+#define ESC_ALT_SCREEN_OFF CSI "?1049l"
+void esc_alt_screen_off(void);
+void esc_falt_screen_off(FILE *);
+
+#define ESC_BRACKETED_PASTE_ON CSI "?2004h"
+void esc_bracketed_paste_on(void);
+void esc_fbracketed_paste_on(FILE *);
+
+#define ESC_BRACKETED_PASTE_OFF CSI "?2004l"
+void esc_bracketed_paste_off(void);
+void esc_fbracketed_paste_off(FILE *);
+
+#define ESC_KITTY_ON CSI ">1u"
+void esc_kitty_on(void);
+void esc_fkitty_on(FILE *);
+
+#define ESC_KITTY_OFF CSI ">0u"
+void esc_kitty_off(void);
+void esc_fkitty_off(FILE *);
+
#ifdef __cplusplus
}
#endif
@@ -121,6 +183,7 @@ void esc_fcursor_down(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uB", (u
void esc_fcursor_left(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uD", (unsigned)n); }
void esc_fcursor_right(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uC", (unsigned)n); }
void esc_fcursor_line_next(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uE", (unsigned)n); }
+void esc_fcursor_previous(FILE *f, u32 n) { if (!f) return; fprintf(f, CSI "%uF", (unsigned)n); }
void esc_fcursor_position(FILE *f, u32 col, u32 row) {
if (!f) return;
fprintf(f, CSI "%u;%uH", (unsigned)row, (unsigned)col);
@@ -151,11 +214,24 @@ void esc_fcolor_bg_256(FILE *f, u32 n) {
fprintf(f, CSI "48;5;%u" ESC_SGR, (unsigned)n);
}
+void esc_fcursor_hide(FILE *f) { if (!f) return; fputs(CSI "?25l", f); }
+void esc_fcursor_show(FILE *f) { if (!f) return; fputs(CSI "?25h", f); }
+void esc_fcursor_save(FILE *f) { if (!f) return; fputs(CSI "s", f); }
+void esc_fcursor_restore(FILE *f) { if (!f) return; fputs(CSI "u", f); }
+void esc_fbell(FILE *f) { if (!f) return; fputc('\a', f); }
+void esc_falt_screen_on(FILE *f) { if (!f) return; fputs(CSI "?1049h", f); }
+void esc_falt_screen_off(FILE *f) { if (!f) return; fputs(CSI "?1049l", f); }
+void esc_fbracketed_paste_on(FILE *f) { if (!f) return; fputs(CSI "?2004h", f); }
+void esc_fbracketed_paste_off(FILE *f) { if (!f) return; fputs(CSI "?2004l", f); }
+void esc_fkitty_on(FILE *f) { if (!f) return; fputs(CSI ">1u", f); }
+void esc_fkitty_off(FILE *f) { if (!f) return; fputs(CSI ">0u", f); }
+
void esc_cursor_up(u32 n) { esc_fcursor_up(stdout, n); }
void esc_cursor_down(u32 n) { esc_fcursor_down(stdout, n); }
void esc_cursor_left(u32 n) { esc_fcursor_left(stdout, n); }
void esc_cursor_right(u32 n) { esc_fcursor_right(stdout, n); }
void esc_cursor_line_next(u32 n) { esc_fcursor_line_next(stdout, n); }
+void esc_cursor_previous(u32 n) { esc_fcursor_previous(stdout, n); }
void esc_cursor_position(u32 col, u32 row) {
esc_fcursor_position(stdout, col, row);
}
@@ -181,4 +257,16 @@ void esc_color_bg_256(u32 n) {
esc_fcolor_bg_256(stdout, n);
}
+void esc_cursor_hide(void) { esc_fcursor_hide(stdout); }
+void esc_cursor_show(void) { esc_fcursor_show(stdout); }
+void esc_cursor_save(void) { esc_fcursor_save(stdout); }
+void esc_cursor_restore(void) { esc_fcursor_restore(stdout); }
+void esc_bell(void) { esc_fbell(stdout); }
+void esc_alt_screen_on(void) { esc_falt_screen_on(stdout); }
+void esc_alt_screen_off(void) { esc_falt_screen_off(stdout); }
+void esc_bracketed_paste_on(void) { esc_fbracketed_paste_on(stdout); }
+void esc_bracketed_paste_off(void) { esc_fbracketed_paste_off(stdout); }
+void esc_kitty_on(void) { esc_fkitty_on(stdout); }
+void esc_kitty_off(void) { esc_fkitty_off(stdout); }
+
#endif // ESCAPE_IMPLEMENTATION \ No newline at end of file
diff --git a/include/frontmatter.h b/include/frontmatter.h
new file mode 100644
index 0000000..56c6935
--- /dev/null
+++ b/include/frontmatter.h
@@ -0,0 +1,1058 @@
+/* frontmatter.h — portable, header-only markdown frontmatter library.
+ *
+ * Parses a markdown document with an optional `---` fenced frontmatter block
+ * into typed values. Zero dependencies: libc only. Not full YAML: simple
+ * `key: value` lines. Values may be quoted (`"..."` or `'...'`). List fields
+ * accept CSV (`tags: a, b, c`) or JSON arrays (`tags: ["a", "b"]`).
+ *
+ * Format:
+ * - Frontmatter is delimited by a leading `---` line and a closing `---`
+ * line. An unterminated fence (no closing `---`) means the whole text is
+ * body — no metadata.
+ * - Lines are `key: value`. Blank lines, `#` comments, and lines without a
+ * colon are skipped. Keys and values are trimmed.
+ * - Value types: `true`/`false` → bool; bare integer → int; `[...]` → list
+ * (JSON array; non-string elements are serialized); anything else →
+ * string (quotes stripped if present).
+ * - The body is everything after the closing fence, trailing whitespace
+ * trimmed. Documents without frontmatter: whole text, trailing
+ * whitespace trimmed.
+ *
+ * Usage: #include "frontmatter.h" in your code. The header is complete on
+ * its own; all functions are static inline, safe to include in any number
+ * of translation units.
+ *
+ * API:
+ * parse_frontmatter(text, &doc) split frontmatter + body
+ * doc_get(&doc, "key") value or NULL
+ * doc_free(&doc) free the document
+ * fm_value_free(&val) free one value
+ * fm_list_items(&val, &items) list-valued field as string items
+ * fm_json_parse(text) embedded JSON value or NULL (invalid)
+ * fm_json_free(v) / fm_json_get(obj, key) / fm_json_serialize(v)
+ * fm_json_merge_owned(base, over) shallow object merge; owns both inputs
+ *
+ * Concurrency: no shared state. Each call is self-contained.
+ */
+#ifndef FRONTMATTER_H
+#define FRONTMATTER_H
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Allocation */
+/* ------------------------------------------------------------------ */
+
+static inline void *fm_xmalloc(size_t n) {
+ void *p = malloc(n ? n : 1);
+ if (!p) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return p;
+}
+
+static inline void *fm_xrealloc(void *p, size_t n) {
+ void *q = realloc(p, n ? n : 1);
+ if (!q) {
+ fprintf(stderr, "frontmatter: out of memory\n");
+ abort();
+ }
+ return q;
+}
+
+static inline char *fm_strdup(const char *s) {
+ size_t n = s ? strlen(s) : 0;
+ char *p = (char *)fm_xmalloc(n + 1);
+ if (s)
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_strndup(const char *s, size_t n) {
+ char *p = (char *)fm_xmalloc(n + 1);
+ memcpy(p, s, n);
+ p[n] = '\0';
+ return p;
+}
+
+static inline char *fm_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 fm_strdup("");
+ char *p = (char *)fm_xmalloc((size_t)n + 1);
+ vsnprintf(p, (size_t)n + 1, fmt, ap);
+ return p;
+}
+
+static inline char *fm_strfmt(const char *fmt, ...) {
+ va_list ap;
+ va_start(ap, fmt);
+ char *s = fm_vsnprintf(fmt, ap);
+ va_end(ap);
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* Growable byte buffer (NUL-terminated on steal) */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char *data;
+ size_t len;
+ size_t cap;
+} fm_buf;
+
+static inline void fm_buf_init(fm_buf *b) {
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_free(fm_buf *b) {
+ free(b->data);
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+}
+
+static inline void fm_buf_reset(fm_buf *b) {
+ b->len = 0;
+}
+
+static inline void fm_buf_reserve(fm_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 *)fm_xrealloc(b->data, cap);
+ b->cap = cap;
+ }
+}
+
+static inline void fm_buf_putn(fm_buf *b, const char *s, size_t n) {
+ fm_buf_reserve(b, n);
+ memcpy(b->data + b->len, s, n);
+ b->len += n;
+ b->data[b->len] = '\0';
+}
+
+static inline void fm_buf_put(fm_buf *b, const char *s) {
+ if (s)
+ fm_buf_putn(b, s, strlen(s));
+}
+
+static inline void fm_buf_push(fm_buf *b, char c) {
+ fm_buf_reserve(b, 1);
+ b->data[b->len++] = c;
+ b->data[b->len] = '\0';
+}
+
+static inline int fm_buf_fmt(fm_buf *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;
+ }
+ fm_buf_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;
+}
+
+static inline char *fm_buf_steal(fm_buf *b) {
+ if (!b->data)
+ b->data = (char *)fm_xmalloc(1);
+ b->data[b->len] = '\0';
+ char *s = b->data;
+ b->data = NULL;
+ b->len = 0;
+ b->cap = 0;
+ return s;
+}
+
+/* ------------------------------------------------------------------ */
+/* String list */
+/* ------------------------------------------------------------------ */
+
+typedef struct {
+ char **items;
+ size_t len;
+ size_t cap;
+} fm_strlist;
+
+static inline void fm_sl_init(fm_strlist *l) {
+ l->items = NULL;
+ l->len = 0;
+ l->cap = 0;
+}
+
+static inline void fm_sl_free(fm_strlist *l) {
+ for (size_t i = 0; i < l->len; i++)
+ free(l->items[i]);
+ free(l->items);
+ fm_sl_init(l);
+}
+
+static inline void fm_sl_push(fm_strlist *l, char *owned) {
+ if (l->len + 1 > l->cap) {
+ l->cap = l->cap ? l->cap * 2 : 8;
+ l->items = (char **)fm_xrealloc(l->items, l->cap * sizeof(char *));
+ }
+ l->items[l->len++] = owned;
+}
+
+static inline void fm_sl_push_copy(fm_strlist *l, const char *s) {
+ fm_sl_push(l, fm_strdup(s));
+}
+
+static inline void fm_sl_pop(fm_strlist *l) {
+ if (!l->len)
+ return;
+ free(l->items[--l->len]);
+}
+
+/* ------------------------------------------------------------------ */
+/* Embedded JSON values */
+/* Object, array, string, number, bool, null. Parse, serialize, */
+/* lookup, shallow-merge. */
+/* ------------------------------------------------------------------ */
+
+enum {
+ FMJ_OBJECT,
+ FMJ_ARRAY,
+ FMJ_STRING,
+ FMJ_NUMBER,
+ FMJ_BOOL,
+ FMJ_NULL,
+};
+
+typedef struct fm_json {
+ int type;
+ char *key; /* OBJECT member key (owned), else NULL */
+ char *str; /* FMJ_STRING value (owned), else NULL */
+ double num; /* FMJ_NUMBER */
+ int is_int; /* FMJ_NUMBER was an integer literal in range */
+ long long i; /* FMJ_NUMBER integral value */
+ int b; /* FMJ_BOOL */
+ struct fm_json **items; /* OBJECT/ARRAY members (owned) */
+ size_t count;
+} fm_json;
+
+static inline fm_json *fm_json_new(int type) {
+ fm_json *v = (fm_json *)fm_xmalloc(sizeof *v);
+ memset(v, 0, sizeof *v);
+ v->type = type;
+ return v;
+}
+
+static inline void fm_json_free(fm_json *v) {
+ if (!v)
+ return;
+ for (size_t i = 0; i < v->count; i++)
+ fm_json_free(v->items[i]);
+ free(v->items);
+ free(v->key);
+ free(v->str);
+ free(v);
+}
+
+static inline const fm_json *fm_json_get(const fm_json *o, const char *key) {
+ if (!o || o->type != FMJ_OBJECT || !key)
+ return NULL;
+ for (size_t i = 0; i < o->count; i++)
+ if (o->items[i] && o->items[i]->key &&
+ strcmp(o->items[i]->key, key) == 0)
+ return o->items[i];
+ return NULL;
+}
+
+/* Max nesting depth for the recursive-descent JSON parser; bounds stack
+ * growth so a pathologically deep input (e.g. 100k nested arrays) is
+ * rejected with an error instead of overflowing the C stack. */
+#define FM_JSON_MAX_DEPTH 256
+
+typedef struct {
+ const char *p;
+ const char *end;
+ int err;
+ size_t depth;
+} fm_jp;
+
+static inline void fm_jskip(fm_jp *s) {
+ while (s->p < s->end && (*s->p == ' ' || *s->p == '\t' || *s->p == '\n' ||
+ *s->p == '\r'))
+ s->p++;
+}
+
+/* String at s->p (opening quote). Returns owned unescaped text. */
+static inline char *fm_jstring_raw(fm_jp *s) {
+ if (s->p >= s->end || *s->p != '"') {
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_buf b;
+ fm_buf_init(&b);
+ while (s->p < s->end && *s->p != '"') {
+ char c = *s->p++;
+ if (c == '\\' && s->p < s->end) {
+ char e = *s->p++;
+ switch (e) {
+ case '"':
+ fm_buf_push(&b, '"');
+ break;
+ case '\\':
+ fm_buf_push(&b, '\\');
+ break;
+ case '/':
+ fm_buf_push(&b, '/');
+ break;
+ case 'b':
+ fm_buf_push(&b, '\b');
+ break;
+ case 'f':
+ fm_buf_push(&b, '\f');
+ break;
+ case 'n':
+ fm_buf_push(&b, '\n');
+ break;
+ case 'r':
+ fm_buf_push(&b, '\r');
+ break;
+ case 't':
+ fm_buf_push(&b, '\t');
+ break;
+ case 'u': {
+ if (s->end - s->p < 4)
+ goto fail;
+ unsigned h = 0;
+ bool ok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ ok = false;
+ break;
+ }
+ h = h * 16 + (unsigned)d;
+ }
+ if (!ok)
+ goto fail;
+ s->p += 4;
+ /* Join a UTF-16 surrogate pair (high \uD800-\uDBFF followed by
+ * low \uDC00-\uDFFF) into a single codepoint so astral chars
+ * (emoji, CJK ext-B, …) emit proper 4-byte UTF-8 rather than
+ * two CESU-8 3-byte sequences. Lone surrogates fall through to
+ * the 3-byte (lenient) encoding below. */
+ unsigned cp = h;
+ if (h >= 0xD800u && h <= 0xDBFFu &&
+ s->end - s->p >= 6 && s->p[0] == '\\' && s->p[1] == 'u') {
+ unsigned lo = 0;
+ bool lok = true;
+ for (int k = 0; k < 4; k++) {
+ char hc = s->p[2 + k];
+ int d;
+ if (hc >= '0' && hc <= '9')
+ d = hc - '0';
+ else if (hc >= 'a' && hc <= 'f')
+ d = hc - 'a' + 10;
+ else if (hc >= 'A' && hc <= 'F')
+ d = hc - 'A' + 10;
+ else {
+ lok = false;
+ break;
+ }
+ lo = lo * 16 + (unsigned)d;
+ }
+ if (lok && lo >= 0xDC00u && lo <= 0xDFFFu) {
+ cp = 0x10000u + ((h - 0xD800u) << 10) + (lo - 0xDC00u);
+ s->p += 6;
+ }
+ }
+ if (cp < 0x80) {
+ fm_buf_push(&b, (char)cp);
+ } else if (cp < 0x800) {
+ fm_buf_push(&b, (char)(0xC0 | (cp >> 6)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else if (cp < 0x10000) {
+ fm_buf_push(&b, (char)(0xE0 | (cp >> 12)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ } else {
+ fm_buf_push(&b, (char)(0xF0 | (cp >> 18)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 12) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | ((cp >> 6) & 0x3F)));
+ fm_buf_push(&b, (char)(0x80 | (cp & 0x3F)));
+ }
+ break;
+ }
+ default:
+ goto fail;
+ }
+ } else if (c < 0x20) {
+ goto fail; /* raw control char: invalid JSON */
+ } else {
+ fm_buf_push(&b, c);
+ }
+ }
+ if (s->p >= s->end || *s->p != '"')
+ goto fail;
+ s->p++;
+ return fm_buf_steal(&b);
+fail:
+ s->err = 1;
+ fm_buf_free(&b);
+ return NULL;
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s);
+
+static inline fm_json *fm_jobject(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* { */
+ fm_json *o = fm_json_new(FMJ_OBJECT);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ char *key = fm_jstring_raw(s);
+ if (!key) {
+ fm_json_free(o);
+ return NULL;
+ }
+ fm_jskip(s);
+ if (s->p >= s->end || *s->p != ':') {
+ free(key);
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+ s->p++;
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ free(key);
+ fm_json_free(o);
+ return NULL;
+ }
+ v->key = key;
+ o->items =
+ (fm_json **)fm_xrealloc(o->items, (o->count + 1) * sizeof(fm_json *));
+ o->items[o->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == '}') {
+ s->p++;
+ s->depth--;
+ return o;
+ }
+ fm_json_free(o);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jarray(fm_jp *s) {
+ if (s->depth >= FM_JSON_MAX_DEPTH) {
+ s->err = 1;
+ return NULL;
+ }
+ s->depth++;
+ s->p++; /* [ */
+ fm_json *a = fm_json_new(FMJ_ARRAY);
+ for (;;) {
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json *v = fm_jvalue(s);
+ if (!v) {
+ fm_json_free(a);
+ return NULL;
+ }
+ a->items =
+ (fm_json **)fm_xrealloc(a->items, (a->count + 1) * sizeof(fm_json *));
+ a->items[a->count++] = v;
+ fm_jskip(s);
+ if (s->p < s->end && *s->p == ',') {
+ s->p++;
+ continue;
+ }
+ if (s->p < s->end && *s->p == ']') {
+ s->p++;
+ s->depth--;
+ return a;
+ }
+ fm_json_free(a);
+ s->err = 1;
+ return NULL;
+ }
+}
+
+static inline fm_json *fm_jvalue(fm_jp *s) {
+ fm_jskip(s);
+ if (s->p >= s->end) {
+ s->err = 1;
+ return NULL;
+ }
+ char c = *s->p;
+ if (c == '{')
+ return fm_jobject(s);
+ if (c == '[')
+ return fm_jarray(s);
+ if (c == '"') {
+ char *t = fm_jstring_raw(s);
+ if (!t)
+ return NULL;
+ fm_json *v = fm_json_new(FMJ_STRING);
+ v->str = t;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "true", 4) == 0) {
+ s->p += 4;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 1;
+ return v;
+ }
+ if (s->end - s->p >= 5 && strncmp(s->p, "false", 5) == 0) {
+ s->p += 5;
+ fm_json *v = fm_json_new(FMJ_BOOL);
+ v->b = 0;
+ return v;
+ }
+ if (s->end - s->p >= 4 && strncmp(s->p, "null", 4) == 0) {
+ s->p += 4;
+ return fm_json_new(FMJ_NULL);
+ }
+ if (c == '-' || (c >= '0' && c <= '9')) {
+ const char *start = s->p;
+ if (c == '-')
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ if (*s->p == '0' && s->p + 1 < s->end && s->p[1] >= '0' &&
+ s->p[1] <= '9') {
+ s->err = 1; /* leading zero */
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ int is_int = 1;
+ if (s->p < s->end && *s->p == '.') {
+ is_int = 0;
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ if (s->p < s->end && (*s->p == 'e' || *s->p == 'E')) {
+ is_int = 0;
+ s->p++;
+ if (s->p < s->end && (*s->p == '+' || *s->p == '-'))
+ s->p++;
+ if (s->p >= s->end || *s->p < '0' || *s->p > '9') {
+ s->err = 1;
+ return NULL;
+ }
+ while (s->p < s->end && *s->p >= '0' && *s->p <= '9')
+ s->p++;
+ }
+ char *endp = NULL;
+ errno = 0;
+ double d = strtod(start, &endp);
+ if (errno != 0 || endp == start) {
+ s->err = 1;
+ return NULL;
+ }
+ fm_json *v = fm_json_new(FMJ_NUMBER);
+ v->num = d;
+ if (is_int && d >= -9.0e18 && d <= 9.0e18) {
+ v->is_int = 1;
+ v->i = (long long)d;
+ }
+ return v;
+ }
+ s->err = 1;
+ return NULL;
+}
+
+static inline fm_json *fm_json_parse(const char *text) {
+ if (!text)
+ return NULL;
+ fm_jp s = {text, text + strlen(text), 0, 0};
+ fm_json *v = fm_jvalue(&s);
+ if (!v)
+ return NULL;
+ fm_jskip(&s);
+ if (s.p != s.end) { /* trailing garbage */
+ fm_json_free(v);
+ return NULL;
+ }
+ return v;
+}
+
+static inline void fm_jstring_out(fm_buf *b, const char *s) {
+ fm_buf_push(b, '"');
+ for (const unsigned char *p = (const unsigned char *)s; *p; p++) {
+ unsigned char c = *p;
+ switch (c) {
+ case '"':
+ fm_buf_put(b, "\\\"");
+ break;
+ case '\\':
+ fm_buf_put(b, "\\\\");
+ break;
+ case '\b':
+ fm_buf_put(b, "\\b");
+ break;
+ case '\f':
+ fm_buf_put(b, "\\f");
+ break;
+ case '\n':
+ fm_buf_put(b, "\\n");
+ break;
+ case '\r':
+ fm_buf_put(b, "\\r");
+ break;
+ case '\t':
+ fm_buf_put(b, "\\t");
+ break;
+ default:
+ if (c < 0x20)
+ fm_buf_fmt(b, "\\u%04x", c);
+ else
+ fm_buf_push(b, (char)c);
+ }
+ }
+ fm_buf_push(b, '"');
+}
+
+static inline void fm_json_out(const fm_json *v, fm_buf *b) {
+ switch (v->type) {
+ case FMJ_NULL:
+ fm_buf_put(b, "null");
+ break;
+ case FMJ_BOOL:
+ fm_buf_put(b, v->b ? "true" : "false");
+ break;
+ case FMJ_STRING:
+ fm_jstring_out(b, v->str ? v->str : "");
+ break;
+ case FMJ_NUMBER:
+ if (v->is_int)
+ fm_buf_fmt(b, "%lld", v->i);
+ else
+ fm_buf_fmt(b, "%.15g", v->num);
+ break;
+ case FMJ_ARRAY:
+ fm_buf_push(b, '[');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, ']');
+ break;
+ case FMJ_OBJECT:
+ fm_buf_push(b, '{');
+ for (size_t i = 0; i < v->count; i++) {
+ if (i)
+ fm_buf_put(b, ",");
+ fm_jstring_out(b, v->items[i]->key ? v->items[i]->key : "");
+ fm_buf_put(b, ":");
+ fm_json_out(v->items[i], b);
+ }
+ fm_buf_push(b, '}');
+ break;
+ }
+}
+
+static inline char *fm_json_serialize(const fm_json *v) {
+ fm_buf b;
+ fm_buf_init(&b);
+ if (v)
+ fm_json_out(v, &b);
+ return fm_buf_steal(&b);
+}
+
+/* Shallow merge of two objects: union of keys, `over` wins per key.
+ * Takes ownership of both inputs (either may be NULL or non-object, in
+ * which case it is dropped). Returns a new object; free with fm_json_free.
+ * Member order: over's keys first, then base-only keys. */
+static inline fm_json *fm_json_merge_owned(const fm_json *base,
+ const fm_json *over) {
+ fm_json *B = (base && base->type == FMJ_OBJECT) ? (fm_json *)base : NULL;
+ fm_json *O = (over && over->type == FMJ_OBJECT) ? (fm_json *)over : NULL;
+ if (base && base->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)base);
+ if (over && over->type != FMJ_OBJECT)
+ fm_json_free((fm_json *)over);
+ fm_json *r = fm_json_new(FMJ_OBJECT);
+ if (O) {
+ for (size_t i = 0; i < O->count; i++)
+ if (O->items[i]) {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = O->items[i];
+ }
+ }
+ if (B) {
+ for (size_t i = 0; i < B->count; i++) {
+ fm_json *m = B->items[i];
+ if (!m)
+ continue;
+ if (O && fm_json_get(O, m->key))
+ fm_json_free(m); /* superseded */
+ else {
+ r->items =
+ (fm_json **)fm_xrealloc(r->items,
+ (r->count + 1) * sizeof(fm_json *));
+ r->items[r->count++] = m;
+ }
+ }
+ }
+ if (B) {
+ free(B->items);
+ free(B->key);
+ free(B->str);
+ free(B);
+ }
+ if (O) {
+ free(O->items);
+ free(O->key);
+ free(O->str);
+ free(O);
+ }
+ return r;
+}
+
+/* ------------------------------------------------------------------ */
+/* Frontmatter */
+/* ------------------------------------------------------------------ */
+
+/* Frontmatter value */
+typedef enum {
+ FM_STR,
+ FM_LIST,
+ FM_BOOL,
+ FM_INT,
+} fm_kind;
+
+typedef struct {
+ fm_kind kind;
+ char *s;
+ char **items;
+ size_t item_count;
+ bool b;
+ long long i;
+} fm_value;
+
+static inline void fm_value_free(fm_value *v) {
+ free(v->s);
+ for (size_t i = 0; i < v->item_count; i++)
+ free(v->items[i]);
+ free(v->items);
+ memset(v, 0, sizeof *v);
+}
+
+/* List-valued field as items: JSON array as-is, CSV split on commas,
+ * single string as one item. */
+static inline void fm_list_items(const fm_value *v, fm_strlist *out) {
+ fm_sl_init(out);
+ if (!v)
+ return;
+ if (v->kind == FM_LIST) {
+ for (size_t i = 0; i < v->item_count; i++)
+ fm_sl_push_copy(out, v->items[i]);
+ return;
+ }
+ if (v->kind != FM_STR || !v->s || !strchr(v->s, ',')) {
+ if (v->s) {
+ size_t a = 0, n = strlen(v->s);
+ while (a < n && isspace((unsigned char)v->s[a]))
+ a++;
+ while (n > a && isspace((unsigned char)v->s[n - 1]))
+ n--;
+ if (n > a)
+ fm_sl_push(out, fm_strndup(v->s + a, n - a));
+ }
+ return;
+ }
+ const char *s = v->s;
+ size_t n = strlen(s);
+ size_t start = 0;
+ for (;;) {
+ while (start < n && isspace((unsigned char)s[start]))
+ start++;
+ if (start >= n)
+ break;
+ const char *comma = (const char *)memchr(s + start, ',', n - start);
+ size_t seg_end = comma ? (size_t)(comma - s) : n;
+ size_t a = start, b = seg_end;
+ while (b > a && isspace((unsigned char)s[b - 1]))
+ b--;
+ if (b > a)
+ fm_sl_push(out, fm_strndup(s + a, b - a));
+ if (!comma)
+ break;
+ start = seg_end + 1;
+ }
+}
+
+/* A frontmatter entry */
+typedef struct {
+ char *key;
+ fm_value val;
+} fm_entry;
+
+/* A markdown document with optional `---` frontmatter */
+typedef struct {
+ fm_entry *entries;
+ size_t count;
+ size_t cap;
+ char *body;
+} doc;
+
+static inline const fm_value *doc_get(const doc *d, const char *key) {
+ for (size_t i = 0; i < d->count; i++)
+ if (strcmp(d->entries[i].key, key) == 0)
+ return &d->entries[i].val;
+ return NULL;
+}
+
+static inline void doc_free(doc *d) {
+ for (size_t i = 0; i < d->count; i++) {
+ free(d->entries[i].key);
+ fm_value_free(&d->entries[i].val);
+ }
+ free(d->entries);
+ free(d->body);
+ memset(d, 0, sizeof *d);
+}
+
+static inline void fm_parse_value(const char *raw, fm_value *v) {
+ memset(v, 0, sizeof *v);
+ if (raw[0] == '[') {
+ fm_json *arr = fm_json_parse(raw);
+ if (arr && arr->type == FMJ_ARRAY) {
+ v->kind = FM_LIST;
+ v->items = (char **)fm_xmalloc(arr->count * sizeof(char *));
+ size_t i = 0;
+ for (size_t k = 0; k < arr->count; k++) {
+ const fm_json *it = arr->items[k];
+ if (it && it->type == FMJ_STRING)
+ v->items[i++] = fm_strdup(it->str);
+ else
+ v->items[i++] = it ? fm_json_serialize(it)
+ : fm_strdup("");
+ }
+ v->item_count = arr->count;
+ fm_json_free(arr);
+ return;
+ }
+ fm_json_free(arr);
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+ return;
+ }
+ if (strcmp(raw, "true") == 0) {
+ v->kind = FM_BOOL;
+ v->b = true;
+ return;
+ }
+ if (strcmp(raw, "false") == 0) {
+ v->kind = FM_BOOL;
+ v->b = false;
+ return;
+ }
+ size_t len = strlen(raw);
+ if (len >= 2 &&
+ ((raw[0] == '"' && raw[len - 1] == '"') ||
+ (raw[0] == '\'' && raw[len - 1] == '\''))) {
+ v->kind = FM_STR;
+ v->s = fm_strndup(raw + 1, len - 2);
+ return;
+ }
+ /* integer? */
+ char *end = NULL;
+ errno = 0;
+ long long n = strtoll(raw, &end, 10);
+ if (errno == 0 && end && *end == '\0' && raw[0] != '\0') {
+ v->kind = FM_INT;
+ v->i = n;
+ return;
+ }
+ v->kind = FM_STR;
+ v->s = fm_strdup(raw);
+}
+
+/* Split text into lines (no trailing empty). Returns line count. */
+static inline char **fm_split_lines(const char *text, size_t *out_count) {
+ size_t cap = 16, n = 0;
+ char **lines = (char **)fm_xmalloc(cap * sizeof(char *));
+ const char *p = text;
+ while (*p) {
+ const char *nl = (const char *)strchr(p, '\n');
+ size_t len = nl ? (size_t)(nl - p) : strlen(p);
+ if (n + 1 > cap) {
+ cap *= 2;
+ lines = (char **)fm_xrealloc(lines, cap * sizeof(char *));
+ }
+ lines[n++] = fm_strndup(p, len);
+ if (!nl)
+ break;
+ p = nl + 1;
+ }
+ *out_count = n;
+ return lines;
+}
+
+static inline void parse_frontmatter(const char *text, doc *out) {
+ memset(out, 0, sizeof *out);
+ size_t nlines = 0;
+ char **lines = fm_split_lines(text, &nlines);
+
+ char *body = fm_strdup(text); /* default: whole text */
+ if (nlines == 0) {
+ /* keep as-is */
+ } else if (strcmp(lines[0], "---") != 0) {
+ /* trim end */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ size_t close = 0;
+ bool found = false;
+ for (size_t i = 1; i < nlines; i++) {
+ if (strcmp(lines[i], "---") == 0) {
+ close = i - 1; /* index within lines[1..] */
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ /* whole text is body, trimmed */
+ size_t blen = strlen(body);
+ while (blen && (body[blen - 1] == '\n' || body[blen - 1] == '\r' ||
+ body[blen - 1] == ' '))
+ body[--blen] = '\0';
+ } else {
+ for (size_t i = 1; i <= close; i++) {
+ const char *line = lines[i];
+ /* trim */
+ size_t start = 0, len = strlen(line);
+ while (start < len && isspace((unsigned char)line[start]))
+ start++;
+ while (len > start &&
+ isspace((unsigned char)line[len - 1]))
+ len--;
+ if (len == 0 || line[start] == '#')
+ continue;
+ const char *colon =
+ (const char *)memchr(line + start, ':', len - start);
+ if (!colon)
+ continue;
+ size_t klen = (size_t)(colon - (line + start));
+ if (klen == 0)
+ continue;
+ char *key = fm_strndup(line + start, klen);
+ const char *vraw = colon + 1;
+ /* trim value */
+ size_t vlen = strlen(vraw);
+ size_t vstart = 0;
+ while (vstart < vlen &&
+ isspace((unsigned char)vraw[vstart]))
+ vstart++;
+ while (vlen > vstart &&
+ isspace((unsigned char)vraw[vlen - 1]))
+ vlen--;
+ fm_value val;
+ char *vtmp = fm_strndup(vraw + vstart, vlen - vstart);
+ fm_parse_value(vtmp, &val);
+ free(vtmp);
+ if (out->count + 1 > out->cap) {
+ out->cap = out->cap ? out->cap * 2 : 8;
+ out->entries =
+ (fm_entry *)fm_xrealloc(out->entries,
+ out->cap *
+ sizeof(out->entries[0]));
+ }
+ out->entries[out->count].key = key;
+ out->entries[out->count].val = val;
+ out->count++;
+ }
+ /* body = lines[2+close..] joined by \n, trimmed end */
+ fm_buf b;
+ fm_buf_init(&b);
+ for (size_t i = 2 + close; i < nlines; i++) {
+ if (i > 2 + close)
+ fm_buf_push(&b, '\n');
+ fm_buf_put(&b, lines[i]);
+ }
+ char *nb = fm_buf_steal(&b);
+ size_t blen = strlen(nb);
+ while (blen && (nb[blen - 1] == '\n' || nb[blen - 1] == '\r' ||
+ nb[blen - 1] == ' '))
+ nb[--blen] = '\0';
+ free(body);
+ body = nb;
+ }
+ }
+ for (size_t i = 0; i < nlines; i++)
+ free(lines[i]);
+ free(lines);
+ out->body = body;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* FRONTMATTER_H */
diff --git a/include/llm.h b/include/llm.h
new file mode 100644
index 0000000..8fc1c7f
--- /dev/null
+++ b/include/llm.h
@@ -0,0 +1,1539 @@
+/*
+ * 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 <curl/curl.h>
+#include <cjson/cJSON.h>
+#include <ctype.h>
+#include <stdarg.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdlib.h>
+#include <string.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ------------------------------------------------------------------ */
+/* Provider + connection */
+/* ------------------------------------------------------------------ */
+
+typedef enum {
+ LLM_OPENAI = 0,
+ LLM_ANTHROPIC,
+ LLM_OLLAMA,
+} llm_provider;
+
+/* 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;
+
+/* ------------------------------------------------------------------ */
+/* 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, 600L);
+ 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;
+ volatile bool *stop;
+} llm_post_ctx;
+
+static inline size_t llm_post_write(char *d, size_t s, size_t n, void *ud) {
+ llm_post_ctx *c = ud;
+ 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 = 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 = clientp;
+ if (c->stop && *c->stop)
+ 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, volatile bool *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;
+
+ 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(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 = llm_xrealloc(c->tc_id, (c->tc_len + 1) * sizeof *c->tc_id);
+ c->tc_name = llm_xrealloc(c->tc_name, (c->tc_len + 1) * sizeof *c->tc_name);
+ c->tc_args = 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;
+ 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 = llm_xrealloc(a->tc_id, (a->tc_len + 1) * sizeof *a->tc_id);
+ a->tc_name =
+ llm_xrealloc(a->tc_name, (a->tc_len + 1) * sizeof *a->tc_name);
+ a->tc_args =
+ 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)) {
+ const cJSON *tc = NULL;
+ cJSON_ArrayForEach(tc, tcs) {
+ 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, a->tc_len, 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 *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;
+ volatile bool *stop;
+ bool stopped; /* set when the transfer should abort */
+ 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 = ud;
+ 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)) {
+ 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 = 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 = clientp;
+ if (c->stop && *c->stop)
+ 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,
+ volatile bool *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(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). */
+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) {
+ 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);
+ }
+ llm_buf_push(req, '}');
+}
+
+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) {
+ llm_buf req;
+ llm_buf_init(&req);
+ if (c->provider == LLM_ANTHROPIC) {
+ llm_buf_put(&req, "{\"model\":");
+ char *model = llm_json_str(c->model);
+ llm_buf_put(&req, model);
+ free(model);
+ llm_buf_put(&req, ",\"max_tokens\":8192,\"stream\":");
+ llm_buf_put(&req, stream ? "true" : "false");
+ llm_buf_push(&req, ',');
+ if (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 *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);
+ } else {
+ llm_emit_openai_body(&req, c, preamble, msgs, n, tools_json, false,
+ stream);
+ }
+ char *out = llm_buf_steal(&req);
+ llm_buf_free(&req);
+ return out;
+}
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* LLM_H */