diff options
Diffstat (limited to 'include')
| -rw-r--r-- | include/clio.h | 1493 | ||||
| -rw-r--r-- | include/dotagents.h | 1282 | ||||
| -rwxr-xr-x | include/escape.h | 88 | ||||
| -rw-r--r-- | include/frontmatter.h | 1058 | ||||
| -rw-r--r-- | include/llm.h | 1539 |
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 */ |