Files
Hermit/src/attach.c
T
WHC 895b47274c
ci / linux (clang) (push) Successful in 4m41s
ci / linux (gcc) (push) Successful in 5m35s
ci / musl (push) Failing after 30s
ci / windows-cross (push) Successful in 9m59s
v0.7.0: image input, model registry, prefix-cache savings
Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
2026-10-08 20:34:09 +08:00

647 lines
19 KiB
C

#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* --- base64 -------------------------------------------------------------- */
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *cst_base64_encode(const void *data, size_t n) {
const unsigned char *p = data;
size_t olen = ((n + 2) / 3) * 4;
char *out = malloc(olen + 1);
if (!out)
return NULL;
size_t o = 0;
for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned)p[i] << 16;
if (i + 1 < n)
v |= (unsigned)p[i + 1] << 8;
if (i + 2 < n)
v |= (unsigned)p[i + 2];
out[o++] = B64[(v >> 18) & 63];
out[o++] = B64[(v >> 12) & 63];
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
out[o++] = i + 2 < n ? B64[v & 63] : '=';
}
out[o] = 0;
return out;
}
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
if (c == '+' || c == '-')
return 62;
if (c == '/' || c == '_')
return 63;
return -1;
}
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
if (out_len)
*out_len = 0;
if (!s)
return NULL;
char *out = malloc(n / 4 * 3 + 4);
if (!out)
return NULL;
size_t o = 0;
unsigned acc = 0;
int bits = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '=')
break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
continue;
int v = b64_val(c);
if (v < 0) {
free(out);
return NULL;
}
acc = (acc << 6) | (unsigned)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (char)((acc >> bits) & 0xFF);
}
}
if (out_len)
*out_len = o;
return out;
}
/* --- format sniffing ----------------------------------------------------- */
static int be32(const unsigned char *p) {
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
}
static int le32(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
}
static int le16(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
}
static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "image/png") == 0)
return "png";
if (strcmp(mime, "image/jpeg") == 0)
return "jpg";
if (strcmp(mime, "image/gif") == 0)
return "gif";
if (strcmp(mime, "image/webp") == 0)
return "webp";
if (strcmp(mime, "image/bmp") == 0)
return "bmp";
return "bin";
}
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
static int jpeg_sof(int marker) {
switch (marker) {
case 0xC0:
case 0xC1:
case 0xC2:
case 0xC3:
case 0xC5:
case 0xC6:
case 0xC7:
case 0xC9:
case 0xCA:
case 0xCB:
case 0xCD:
case 0xCE:
case 0xCF:
return 1;
default:
return 0;
}
}
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
const unsigned char *p = data;
const char *m = NULL;
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
m = "image/png";
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
m = "image/jpeg";
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
m = "image/gif";
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
m = "image/gif";
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
m = "image/webp";
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
m = "image/bmp";
if (!m)
return -1;
snprintf(mime, mime_sz, "%s", m);
return 0;
}
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
* container flavours. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
const unsigned char *p = data;
*w = *h = 0;
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
return -1;
if (strcmp(mime, "image/png") == 0) {
/* 8 signature + 4 length + "IHDR" + w + h */
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
return -1;
*w = be32(p + 16);
*h = be32(p + 20);
return 0;
}
if (strcmp(mime, "image/gif") == 0) {
if (n < 10)
return -1;
*w = le16(p + 6);
*h = le16(p + 8);
return 0;
}
if (strcmp(mime, "image/bmp") == 0) {
if (n < 26)
return -1;
int wi = le32(p + 18);
int hi = le32(p + 22);
if (wi < 0)
wi = -wi; /* bottom-up rows are stored with a negative height */
if (hi < 0)
hi = -hi;
*w = wi;
*h = hi;
return 0;
}
if (strcmp(mime, "image/webp") == 0) {
if (n < 30)
return -1;
const unsigned char *c = p + 12;
if (memcmp(c, "VP8 ", 4) == 0) {
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
return -1;
*w = le16(c + 10) & 0x3FFF;
*h = le16(c + 12) & 0x3FFF;
return 0;
}
if (memcmp(c, "VP8L", 4) == 0) {
if (n < 25 || c[8] != 0x2F)
return -1;
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
*w = (int)(b & 0x3FFF) + 1;
*h = (int)((b >> 14) & 0x3FFF) + 1;
return 0;
}
if (memcmp(c, "VP8X", 4) == 0) {
if (n < 30)
return -1;
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
((unsigned)c[14] << 16)) + 1;
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
((unsigned)c[17] << 16)) + 1;
return 0;
}
return -1;
}
if (strcmp(mime, "image/jpeg") == 0) {
size_t i = 2;
while (i + 3 < n) {
if (p[i] != 0xFF)
return -1; /* lost sync */
unsigned marker = p[i + 1];
i += 2;
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
continue; /* standalone markers: no payload */
if (marker == 0xD9 || marker == 0x01)
continue;
if (i + 1 >= n)
return -1;
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
if (seg < 2 || i + seg > n)
return -1;
if (jpeg_sof((int)marker) && seg >= 7) {
*h = (p[i + 3] << 8) | p[i + 4];
*w = (p[i + 5] << 8) | p[i + 6];
return 0;
}
i += seg;
}
}
return -1;
}
/* --- storage ------------------------------------------------------------- */
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
* workspace keeps the two together. */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
if (!session_path || !session_path[0])
return -1;
char dir[OS_PATH_MAX];
snprintf(dir, sizeof(dir), "%s", session_path);
char *slash = strrchr(dir, '/');
#ifdef _WIN32
char *bslash = strrchr(dir, '\\');
if (bslash && (!slash || bslash > slash))
slash = bslash;
#endif
char leaf[256];
const char *base = slash ? slash + 1 : dir;
size_t bl = strlen(base);
if (bl >= sizeof(leaf))
bl = sizeof(leaf) - 1;
memcpy(leaf, base, bl);
leaf[bl] = 0;
if (slash)
*(slash + 1) = 0;
else {
dir[0] = '.';
dir[1] = 0;
}
char *dot = strstr(leaf, ".jsonl");
if (dot)
*dot = 0;
char rel[320];
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, rel) != 0)
return -1;
if (os_mkdir_p(full, 0700) != 0)
return -1;
snprintf(out, out_sz, "%s", full);
return 0;
}
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
* (hand-edited or crafted) could point outside the attachment directory. */
int cst_attach_rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '.')
return 0;
for (const char *p = rel; *p; p++) {
char c = *p;
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
return 0;
}
return strstr(rel, "..") == NULL;
}
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz) {
if (!a || !cst_attach_rel_ok(a->rel))
return -1;
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, out_sz, dir, a->rel);
}
static char *read_whole(const char *path, size_t *out_len, size_t max) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
cst_buf_t b = {0};
char chunk[16384];
size_t n;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
cst_buf_free(&b);
fclose(f);
return NULL;
}
}
fclose(f);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */
static void make_id(const char *name, const char *data, size_t n, char *out,
size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
}
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin,
char *err, size_t err_sz) {
if (!name || !name[0])
name = "image";
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory");
return NULL;
}
cst_attachment_t *a = calloc(1, sizeof(*a));
if (!a) {
snprintf(err, err_sz, "out of memory");
return NULL;
}
make_id(name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->bytes = (long long)n;
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment path is too long");
free(a);
return NULL;
}
FILE *f = fopen(full, "wb");
if (!f) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
size_t w = fwrite(data, 1, n, f);
if (fclose(f) != 0 || w != n) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
return a;
}
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the image in");
return NULL;
}
if (!data || !n) {
snprintf(err, err_sz, "image is empty");
return NULL;
}
return store(session_path, name, data, n, "user", err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
free(data);
return a;
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment has no stored file");
return NULL;
}
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session "
"store",
a->name);
return NULL;
}
char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
a->name);
free(data);
return NULL;
}
return data;
}
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
char err[256];
size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
if (!data)
return NULL;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64)
return NULL;
cst_buf_t b = {0};
cst_buf_printf(&b, "data:%s;base64,", mime);
cst_buf_puts(&b, b64);
free(b64);
return cst_buf_steal(&b);
}
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
if (!a) {
snprintf(out, out_sz, "(none)");
return;
}
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
char dim[32] = "";
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
out[0] = 0;
if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
(double)a->bytes / (1024 * 1024));
else
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
(double)a->bytes / 1024);
}
/* --- lifetime ------------------------------------------------------------ */
void cst_attach_free(cst_attachment_t *a) { free(a); }
/* Entries always live in one array (a 1-element array for a single import),
* so freeing the block releases them all. */
void cst_attachments_free(cst_attachment_t *list, int n) {
(void)n;
free(list);
}
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
if (!out)
return NULL;
for (int i = 0; i < n; i++)
out[i] = list[i];
return out;
}
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src) {
if (!list || !n || !cap || !src)
return -1;
if (*n == *cap) {
int nc = *cap ? *cap * 2 : 4;
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
if (!nv)
return -1;
*list = nv;
*cap = nc;
}
(*list)[(*n)++] = *src;
return 0;
}
/* --- JSONL --------------------------------------------------------------- */
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cJSON *arr = cJSON_CreateArray();
if (!arr)
return NULL;
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", a->id);
cJSON_AddStringToObject(o, "name", a->name);
cJSON_AddStringToObject(o, "mime", a->mime);
cJSON_AddStringToObject(o, "rel", a->rel);
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height);
}
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin);
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return s;
}
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
if (n_out)
*n_out = 0;
const cJSON *arr = array_json;
if (!cJSON_IsArray(arr))
return NULL;
int n = cJSON_GetArraySize(arr);
if (n <= 0)
return NULL;
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
if (!list)
return NULL;
int k = 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, arr) {
if (!cJSON_IsObject(it))
continue;
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
!cst_attach_rel_ok(jrel->valuestring))
continue;
cst_attachment_t *a = &list[k];
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
snprintf(a->name, sizeof(a->name), "%s",
cJSON_IsString(v) ? v->valuestring : a->id);
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
snprintf(a->mime, sizeof(a->mime), "%s",
cJSON_IsString(v) ? v->valuestring : "image/*");
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "width");
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto");
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : "");
k++;
}
if (k == 0) {
free(list);
return NULL;
}
if (n_out)
*n_out = k;
return list;
}