Files
neuron-web/runtime/el_runtime.c
T
will.anderson 5ce5f4a8be
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m34s
fix: binary asset serving + checkout centering
el_runtime: http_response() JSON-encoded the body via jb_emit_escaped(),
which stops at the first null byte. PNG/binary files contain null bytes
at byte 8 (IHDR chunk length), so only 8 bytes were served — browsers
received a corrupt/truncated image and showed broken icons.

Fix: when _tl_fs_read_len > 0 (binary fs_read), copy raw bytes into a
thread-local side-channel (_tl_binary_body/_tl_binary_size) and write
the sentinel "__el_binary__" into the envelope body field. http_send_response()
detects the sentinel and substitutes the real bytes for sending.

checkout.el: .checkout-shell, .checkout-summary, and .checkout-form-wrap
had no CSS, leaving the page left-aligned and single-column. Added grid
layout (2-col desktop, 1-col mobile), max-width centering, and sticky
order summary.
2026-05-11 20:10:19 -05:00

11635 lines
465 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* el_runtime.c — El language C runtime implementation
*
* All functions use el_val_t (= int64_t) as the universal value type.
* Strings are transported as their pointer address cast to int64_t.
* On any 64-bit system sizeof(pointer) <= sizeof(int64_t), so this is safe.
*
* Compile with:
* cc -std=c11 -I<runtime-dir> -lcurl -lpthread -o <prog> <prog>.c el_runtime.c
*
* Link requirements: -lcurl (HTTP client + LLM), -lpthread (HTTP server).
*/
/* Feature-test macros must be set before any standard headers. _GNU_SOURCE
* exposes clock_gettime/CLOCK_REALTIME, strcasecmp, and the dlfcn extensions
* (RTLD_DEFAULT) — all of which macOS hands us without asking but glibc on
* Debian gates behind an explicit opt-in. */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "el_runtime.h"
#include <stdarg.h>
#include <strings.h> /* strcasecmp */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <math.h>
#include <time.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <dlfcn.h> /* dlsym for http_set_handler fallback */
#include <unistd.h>
#include <fcntl.h>
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
#include <sys/resource.h> /* getrusage — memory guard */
#ifdef HAVE_CURL
#include <curl/curl.h>
#endif
/* ── Internal allocators ─────────────────────────────────────────────────── */
/*
* Per-request string arena
*
* Every El string allocated via el_strbuf / el_strdup during an HTTP request
* is registered in a thread-local arena. When el_request_end() is called at
* the end of the worker thread, every arena entry is freed — recovering all
* the intermediate strings from el_str_concat chains (build_system_prompt,
* engram_compile, etc.) that are otherwise leaked forever.
*
* Long-lived allocations (state_set values, engram internal storage) call
* el_strdup_persist() / el_strbuf_persist() which bypass the arena entirely.
*/
#define EL_ARENA_INITIAL 512
typedef struct {
char** ptrs;
size_t count;
size_t cap;
} ElArena;
static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
static _Thread_local int _tl_arena_active = 0;
/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
* Allows serving PNGs and other binary files without strlen truncation. */
static _Thread_local size_t _tl_fs_read_len = 0;
/* Binary body side-channel for http_response().
*
* http_response() normally JSON-encodes the body via jb_emit_escaped(), which
* stops at the first null byte (C-string semantics). Binary files like PNGs
* contain null bytes as early as byte 8 (IHDR chunk length), causing truncation.
*
* When _tl_fs_read_len > 0 at the time http_response() is called, we skip
* JSON-encoding and instead:
* 1. malloc-copy the raw bytes here
* 2. write the sentinel string "__el_binary__" into the envelope body field
* 3. In http_send_response(), detect the sentinel and use these raw bytes
*
* Thread-local so each worker thread has independent storage.
* Lifecycle: set by http_response(), consumed (and freed) by http_send_response(). */
static _Thread_local char* _tl_binary_body = NULL;
static _Thread_local size_t _tl_binary_size = 0;
static void el_arena_track(char* p) {
if (!_tl_arena_active || !p) return;
if (_tl_arena.count >= _tl_arena.cap) {
size_t nc = _tl_arena.cap == 0 ? EL_ARENA_INITIAL : _tl_arena.cap * 2;
char** grown = realloc(_tl_arena.ptrs, nc * sizeof(char*));
if (!grown) return; /* can't track — will leak this one ptr, but don't crash */
_tl_arena.ptrs = grown;
_tl_arena.cap = nc;
}
_tl_arena.ptrs[_tl_arena.count++] = p;
}
/* Called by http_worker before dispatching the El handler. */
void el_request_start(void) {
_tl_arena.count = 0;
_tl_arena_active = 1;
}
/* Called by http_worker after the El handler returns and the response is sent.
* Frees every intermediate string allocated during the request. */
void el_request_end(void) {
_tl_arena_active = 0;
for (size_t i = 0; i < _tl_arena.count; i++) {
free(_tl_arena.ptrs[i]);
}
_tl_arena.count = 0;
}
/* ── Scoped arena for CLI use ─────────────────────────────────────────────── *
* CLI programs never call el_request_start/end, so all strdup allocations are
* permanent. el_arena_push/pop let the compiler free intermediate strings
* after each compilation unit.
*
* el_arena_push() — activates the arena if not already active, saves the
* current arena count as a mark, and returns it as an el_val_t Int.
* el_arena_pop(mark) — frees all strings allocated since the push mark and
* resets the count. If count reaches 0, deactivates the arena.
*/
#define EL_ARENA_SCOPE_DEPTH 32
static _Thread_local size_t _tl_arena_scope[EL_ARENA_SCOPE_DEPTH];
static _Thread_local int _tl_arena_scope_depth = 0;
el_val_t el_arena_push(void) {
if (!_tl_arena_active) {
_tl_arena_active = 1;
}
if (_tl_arena_scope_depth < EL_ARENA_SCOPE_DEPTH) {
_tl_arena_scope[_tl_arena_scope_depth++] = _tl_arena.count;
}
return (el_val_t)(int64_t)_tl_arena.count;
}
el_val_t el_arena_pop(el_val_t mark) {
size_t save = (size_t)(int64_t)mark;
if (save > _tl_arena.count) save = 0;
for (size_t i = save; i < _tl_arena.count; i++) {
if (_tl_arena.ptrs[i]) {
free(_tl_arena.ptrs[i]);
_tl_arena.ptrs[i] = NULL;
}
}
_tl_arena.count = save;
if (_tl_arena_scope_depth > 0) _tl_arena_scope_depth--;
if (save == 0) _tl_arena_active = 0;
return 0;
}
/* Persistent allocation — bypasses the arena (state_set, engram internals). */
static char* el_strdup_persist(const char* s) {
if (!s) return strdup("");
return strdup(s);
}
static char* el_strbuf_persist(size_t n) {
char* p = malloc(n + 1);
if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
p[0] = '\0';
return p;
}
static char* el_strdup(const char* s) {
if (!s) { char* p = strdup(""); el_arena_track(p); return p; }
char* p = strdup(s);
el_arena_track(p);
return p;
}
static char* el_strbuf(size_t n) {
char* p = malloc(n + 1);
if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
p[0] = '\0';
el_arena_track(p);
return p;
}
/* Wrap an allocated C string as el_val_t */
static el_val_t el_wrap_str(char* s) {
return EL_STR(s);
}
/* ── I/O ──────────────────────────────────────────────────────────────────── */
el_val_t println(el_val_t s) {
const char* str = EL_CSTR(s);
if (str) puts(str);
else puts("");
return 0;
}
el_val_t print(el_val_t s) {
const char* str = EL_CSTR(s);
if (str) fputs(str, stdout);
return 0;
}
el_val_t readline(void) {
char buf[4096];
if (!fgets(buf, sizeof(buf), stdin)) return el_wrap_str(el_strdup(""));
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
return el_wrap_str(el_strdup(buf));
}
/* __read_n — read exactly n bytes from stdin.
* Allocates a buffer of size n+1, calls fread(buf, 1, n, stdin) to read
* exactly n raw bytes (including \r, \n, NUL, etc.), null-terminates, and
* returns the buffer as an El String. Returns "" on EOF or I/O error.
*
* Used by the El LSP server to read JSON-RPC message bodies after parsing
* the Content-Length header. readline() cannot be used for the body because
* it stops at the first \n and LSP JSON bodies are not newline-terminated. */
el_val_t __read_n(el_val_t nv) {
int64_t n = EL_INT(nv);
if (n <= 0) return el_wrap_str(el_strdup(""));
char* buf = malloc((size_t)n + 1);
if (!buf) { fputs("el_runtime: __read_n: out of memory\n", stderr); return el_wrap_str(el_strdup("")); }
size_t got = fread(buf, 1, (size_t)n, stdin);
buf[got] = '\0';
if (got == 0) { free(buf); return el_wrap_str(el_strdup("")); }
/* Track in arena so the allocation is freed when the request ends. */
el_arena_track(buf);
return el_wrap_str(buf);
}
/* __print_raw — write a string to stdout without any modification.
* Unlike println/print (which call puts/fputs and may add newlines or flush
* in platform-specific ways), this uses fwrite with the exact byte count so
* that embedded \r\n pairs in LSP Content-Length headers survive intact. */
void __print_raw(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return;
size_t len = strlen(s);
fwrite(s, 1, len, stdout);
fflush(stdout);
}
/* ── String builtins ─────────────────────────────────────────────────────── */
el_val_t el_str_concat(el_val_t av, el_val_t bv) {
const char* a = EL_CSTR(av);
const char* b = EL_CSTR(bv);
if (!a) a = "";
if (!b) b = "";
size_t la = strlen(a);
size_t lb = strlen(b);
char* out = el_strbuf(la + lb);
memcpy(out, a, la);
memcpy(out + la, b, lb);
out[la + lb] = '\0';
return el_wrap_str(out);
}
el_val_t str_eq(el_val_t av, el_val_t bv) {
const char* a = EL_CSTR(av);
const char* b = EL_CSTR(bv);
if (!a || !b) return (el_val_t)(a == b);
return (el_val_t)(strcmp(a, b) == 0);
}
el_val_t str_starts_with(el_val_t sv, el_val_t prefv) {
const char* s = EL_CSTR(sv);
const char* prefix = EL_CSTR(prefv);
if (!s || !prefix) return 0;
size_t lp = strlen(prefix);
return (el_val_t)(strncmp(s, prefix, lp) == 0);
}
el_val_t str_ends_with(el_val_t sv, el_val_t sufv) {
const char* s = EL_CSTR(sv);
const char* suffix = EL_CSTR(sufv);
if (!s || !suffix) return 0;
size_t ls = strlen(s);
size_t lsuf = strlen(suffix);
if (lsuf > ls) return 0;
return (el_val_t)(strcmp(s + ls - lsuf, suffix) == 0);
}
el_val_t str_len(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
return (el_val_t)strlen(s);
}
el_val_t str_concat(el_val_t a, el_val_t b) {
return el_str_concat(a, b);
}
el_val_t int_to_str(el_val_t n) {
char buf[32];
snprintf(buf, sizeof(buf), "%lld", (long long)n);
return el_wrap_str(el_strdup(buf));
}
el_val_t str_to_int(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
return (el_val_t)atoll(s);
}
/* native_str_to_int — El compiler-generated alias for str_to_int.
* Converts a string el_val_t to its integer representation. */
el_val_t native_str_to_int(el_val_t sv) { return str_to_int(sv); }
el_val_t str_slice(el_val_t sv, el_val_t start, el_val_t end) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
int64_t len = (int64_t)strlen(s);
if (start < 0) start = 0;
if (end > len) end = len;
if (start >= end) return el_wrap_str(el_strdup(""));
int64_t sz = end - start;
char* out = el_strbuf((size_t)sz);
memcpy(out, s + start, (size_t)sz);
out[sz] = '\0';
return el_wrap_str(out);
}
el_val_t str_contains(el_val_t sv, el_val_t subv) {
const char* s = EL_CSTR(sv);
const char* sub = EL_CSTR(subv);
if (!s || !sub) return 0;
return (el_val_t)(strstr(s, sub) != NULL);
}
el_val_t str_replace(el_val_t sv, el_val_t fromv, el_val_t tov) {
const char* s = EL_CSTR(sv);
const char* from = EL_CSTR(fromv);
const char* to = EL_CSTR(tov);
if (!s || !from || !to) return el_wrap_str(el_strdup(s ? s : ""));
size_t ls = strlen(s);
size_t lf = strlen(from);
size_t lt = strlen(to);
if (lf == 0) return el_wrap_str(el_strdup(s));
size_t count = 0;
const char* p = s;
while ((p = strstr(p, from)) != NULL) { count++; p += lf; }
size_t out_sz = ls + count * lt + 1;
char* out = el_strbuf(out_sz);
char* dst = out;
p = s;
const char* found;
while ((found = strstr(p, from)) != NULL) {
size_t chunk = (size_t)(found - p);
memcpy(dst, p, chunk); dst += chunk;
memcpy(dst, to, lt); dst += lt;
p = found + lf;
}
strcpy(dst, p);
return el_wrap_str(out);
}
el_val_t str_to_upper(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
char* out = el_strbuf(n);
for (size_t i = 0; i < n; i++) out[i] = (char)toupper((unsigned char)s[i]);
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t str_to_lower(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
char* out = el_strbuf(n);
for (size_t i = 0; i < n; i++) out[i] = (char)tolower((unsigned char)s[i]);
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t str_trim(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
while (*s && isspace((unsigned char)*s)) s++;
size_t n = strlen(s);
while (n > 0 && isspace((unsigned char)s[n - 1])) n--;
char* out = el_strbuf(n);
memcpy(out, s, n);
out[n] = '\0';
return el_wrap_str(out);
}
/* ── Math ────────────────────────────────────────────────────────────────── */
el_val_t el_abs(el_val_t n) { return n < 0 ? -n : n; }
el_val_t el_max(el_val_t a, el_val_t b) { return a > b ? a : b; }
el_val_t el_min(el_val_t a, el_val_t b) { return a < b ? a : b; }
/* ── Refcounted heap objects ──────────────────────────────────────────────────
*
* ElList and ElMap carry a magic-tagged header at offset 0:
* { uint32_t magic; uint32_t refcount; ... payload ... }
*
* The magic tag distinguishes refcounted objects from raw C strings (whose
* first byte is printable ASCII < 0x80) and from small integers (which can't
* be dereferenced). el_retain / el_release sniff the magic and act only on
* matching values; everything else is a safe no-op.
*
* Both ElList and ElMap use INDIRECTION: the header is fixed-size and never
* moves. The payload arrays (elems, keys, values) live in separate heap
* allocations, so realloc-grow on append never invalidates the caller's
* pointer to the header. This is what lets us mutate-in-place safely when
* the refcount is 1 and copy-on-write when it's higher.
*
* Memory model in practice:
* Single-owner accumulator (the cg_stmts pattern) — refcount stays at 1,
* appends amortize to O(1), total memory O(N) for an N-element list.
* Multi-owner branching (the cg_if_stmt pattern) — refcount > 1, each
* append on a shared list copies, so the original is preserved for the
* else-branch. Persistent semantics where they're needed; mutation where
* they're not. */
#define EL_MAGIC_LIST 0xE15710A1u /* >= 0x80 in MSB so 'looks_like_string' rejects */
#define EL_MAGIC_MAP 0xE19A704Bu
typedef struct {
uint32_t magic;
uint32_t refcount;
} ElHeader;
/* ── List ────────────────────────────────────────────────────────────────── */
typedef struct {
ElHeader hdr;
int64_t length;
int64_t capacity;
el_val_t* elems;
} ElList;
static ElList* list_alloc(int64_t cap) {
if (cap < 4) cap = 4;
ElList* lst = malloc(sizeof(ElList));
if (!lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
lst->hdr.magic = EL_MAGIC_LIST;
lst->hdr.refcount = 1;
lst->length = 0;
lst->capacity = cap;
lst->elems = malloc((size_t)cap * sizeof(el_val_t));
if (!lst->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
return lst;
}
el_val_t el_list_empty(void) {
return EL_STR(list_alloc(4));
}
el_val_t el_list_new(el_val_t count, ...) {
ElList* lst = list_alloc(count > 0 ? count : 4);
va_list ap;
va_start(ap, count);
for (int64_t i = 0; i < count; i++) {
lst->elems[i] = va_arg(ap, el_val_t);
}
va_end(ap);
lst->length = count;
return EL_STR(lst);
}
el_val_t el_list_len(el_val_t listv) {
ElList* lst = (ElList*)(uintptr_t)listv;
if (!lst) return 0;
return lst->length;
}
el_val_t el_list_get(el_val_t listv, el_val_t index) {
ElList* lst = (ElList*)(uintptr_t)listv;
if (!lst) return 0;
if (index < 0 || index >= lst->length) return 0;
return lst->elems[index];
}
el_val_t el_list_append(el_val_t listv, el_val_t elem) {
ElList* old = (ElList*)(uintptr_t)listv;
if (!old) {
ElList* fresh = list_alloc(4);
fresh->elems[0] = elem;
fresh->length = 1;
return EL_STR(fresh);
}
/* Uniquely owned: grow the elems buffer in place. The header pointer the
* caller holds doesn't move (we only realloc the inner array). This is
* the common case in compiler accumulators, and it's amortized O(1). */
if (old->hdr.refcount <= 1) {
if (old->length >= old->capacity) {
int64_t new_cap = old->capacity > 0 ? old->capacity * 2 : 4;
el_val_t* grown = realloc(old->elems, (size_t)new_cap * sizeof(el_val_t));
if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
old->elems = grown;
old->capacity = new_cap;
}
old->elems[old->length++] = elem;
return listv;
}
/* Shared: copy-on-write. The original is preserved for its other owners. */
int64_t new_cap = old->length + 1;
if (new_cap < 4) new_cap = 4;
ElList* fresh = malloc(sizeof(ElList));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length + 1;
fresh->capacity = new_cap;
fresh->elems = malloc((size_t)new_cap * sizeof(el_val_t));
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
}
fresh->elems[old->length] = elem;
return EL_STR(fresh);
}
el_val_t el_list_clone(el_val_t listv) {
/* Shallow copy: the new ElList owns its own header and elems buffer, but
* the elements themselves are shared (which is what callers want for the
* cg_if_stmt 'declared' pattern — cloning the spine, not its contents).
* Used by codegen at scope branch points where two child scopes need to
* see the same starting set of declared names without each other's
* mutations. */
ElList* old = (ElList*)(uintptr_t)listv;
if (!old) return el_list_empty();
int64_t cap = old->capacity > 0 ? old->capacity : 4;
if (cap < old->length) cap = old->length;
if (cap < 4) cap = 4;
ElList* fresh = malloc(sizeof(ElList));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_LIST;
fresh->hdr.refcount = 1;
fresh->length = old->length;
fresh->capacity = cap;
fresh->elems = malloc((size_t)cap * sizeof(el_val_t));
if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (old->length > 0) {
memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
}
return EL_STR(fresh);
}
/* ── Map ─────────────────────────────────────────────────────────────────── */
typedef struct {
ElHeader hdr;
int64_t count;
int64_t capacity;
el_val_t* keys;
el_val_t* values;
} ElMap;
static ElMap* map_alloc(int64_t cap) {
if (cap < 4) cap = 4;
ElMap* m = malloc(sizeof(ElMap));
if (!m) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
m->hdr.magic = EL_MAGIC_MAP;
m->hdr.refcount = 1;
m->count = 0;
m->capacity = cap;
m->keys = malloc((size_t)cap * sizeof(el_val_t));
m->values = malloc((size_t)cap * sizeof(el_val_t));
if (!m->keys || !m->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
return m;
}
el_val_t el_map_new(el_val_t pair_count, ...) {
ElMap* m = map_alloc(pair_count > 0 ? pair_count : 4);
va_list ap;
va_start(ap, pair_count);
for (int64_t i = 0; i < pair_count; i++) {
m->keys[i] = va_arg(ap, el_val_t);
m->values[i] = va_arg(ap, el_val_t);
}
va_end(ap);
m->count = pair_count;
return EL_STR(m);
}
static ElMap* as_map(el_val_t v) { return (ElMap*)(uintptr_t)v; }
el_val_t el_map_get(el_val_t mapv, el_val_t keyv) {
ElMap* m = as_map(mapv);
const char* key = EL_CSTR(keyv);
if (!m || !key) return 0;
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
if (k && strcmp(k, key) == 0) return m->values[i];
}
return 0;
}
el_val_t el_get_field(el_val_t mapv, el_val_t keyv) {
return el_map_get(mapv, keyv);
}
/* Internal: in-place set on a uniquely-owned map. */
static el_val_t map_set_in_place(ElMap* m, el_val_t keyv, el_val_t value) {
const char* key = EL_CSTR(keyv);
if (key) {
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
if (k && strcmp(k, key) == 0) { m->values[i] = value; return EL_STR(m); }
}
}
if (m->count >= m->capacity) {
int64_t new_cap = m->capacity > 0 ? m->capacity * 2 : 4;
el_val_t* gk = realloc(m->keys, (size_t)new_cap * sizeof(el_val_t));
el_val_t* gv = realloc(m->values, (size_t)new_cap * sizeof(el_val_t));
if (!gk || !gv) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
m->keys = gk;
m->values = gv;
m->capacity = new_cap;
}
m->keys[m->count] = keyv;
m->values[m->count] = value;
m->count++;
return EL_STR(m);
}
el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
ElMap* m = as_map(mapv);
if (!m) return 0;
if (m->hdr.refcount <= 1) {
return map_set_in_place(m, keyv, value);
}
/* Shared: copy then set. The original is preserved for its other owners. */
int64_t new_cap = m->count + 1;
if (new_cap < 4) new_cap = 4;
ElMap* fresh = malloc(sizeof(ElMap));
if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
fresh->hdr.magic = EL_MAGIC_MAP;
fresh->hdr.refcount = 1;
fresh->count = m->count;
fresh->capacity = new_cap;
fresh->keys = malloc((size_t)new_cap * sizeof(el_val_t));
fresh->values = malloc((size_t)new_cap * sizeof(el_val_t));
if (!fresh->keys || !fresh->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
if (m->count > 0) {
memcpy(fresh->keys, m->keys, (size_t)m->count * sizeof(el_val_t));
memcpy(fresh->values, m->values, (size_t)m->count * sizeof(el_val_t));
}
return map_set_in_place(fresh, keyv, value);
}
/* ── Refcount ops ─────────────────────────────────────────────────────────── */
/*
* Both retain and release sniff the magic header to decide whether a value
* is a refcounted heap object. For small integers, raw C strings, and any
* value whose magic word doesn't match, both functions are no-ops. This lets
* codegen emit them on every let-binding without having to track types.
*
* Safety: we filter out obvious non-pointers (small magnitudes, misaligned
* addresses) before dereferencing. For any value that passes the filter and
* lives in a mapped page, reading the first 4 bytes is safe — strings start
* with printable ASCII (< 0x80), so their magic word will never collide with
* EL_MAGIC_LIST (0xE1...) or EL_MAGIC_MAP (0xE1...). Random integers that
* happen to look like aligned heap pointers are exceedingly unlikely to land
* on a page whose first 4 bytes match either magic. */
static int looks_like_heap_obj(el_val_t v) {
if (v == 0) return 0;
int64_t s = (int64_t)v;
if (s > -0x10000 && s < 0x10000) return 0; /* small ints */
uintptr_t p = (uintptr_t)v;
if (p < 0x10000) return 0; /* low addresses */
if (p & 0x7) return 0; /* malloc returns 8-aligned */
return 1;
}
void el_retain(el_val_t v) {
if (!looks_like_heap_obj(v)) return;
ElHeader* h = (ElHeader*)(uintptr_t)v;
if (h->magic == EL_MAGIC_LIST || h->magic == EL_MAGIC_MAP) {
h->refcount++;
}
}
void el_release(el_val_t v) {
if (!looks_like_heap_obj(v)) return;
ElHeader* h = (ElHeader*)(uintptr_t)v;
if (h->magic == EL_MAGIC_LIST) {
if (h->refcount > 0 && --h->refcount == 0) {
ElList* l = (ElList*)h;
free(l->elems);
l->hdr.magic = 0; /* poison so use-after-free is detected */
free(l);
}
} else if (h->magic == EL_MAGIC_MAP) {
if (h->refcount > 0 && --h->refcount == 0) {
ElMap* m = (ElMap*)h;
free(m->keys);
free(m->values);
m->hdr.magic = 0;
free(m);
}
}
}
/* ── Batch 2/3 forward decls (defined later in JSON section) ────────────── */
typedef struct JsonBuf JsonBuf;
typedef struct JsonParser JsonParser;
static void jb_init(JsonBuf* b);
static void jb_putc(JsonBuf* b, char c);
static void jb_puts(JsonBuf* b, const char* s);
static void jb_emit_escaped(JsonBuf* b, const char* s);
static int looks_like_string(el_val_t v);
static const char* json_find_key(const char* s, const char* key);
static const char* json_skip_value(const char* p);
static char* jp_parse_string_raw(JsonParser* jp);
/* Struct definitions are visible here because batch 2/3 helpers above use
* them by value; the bodies (jb_init, etc.) appear in the JSON section. */
struct JsonBuf {
char* buf;
size_t len;
size_t cap;
};
struct JsonParser {
const char* p;
const char* end;
int err;
};
/* ── Batch 2: Real HTTP (libcurl client + POSIX-socket server) ───────────── */
/*
* Client: blocking libcurl easy-handle calls. Errors are returned as a JSON
* fragment {"error":"..."} so callers can detect via str_starts_with("{") /
* json_get_string("error", ...).
*
* Server: bind/listen/accept loop on a TCP socket. Each accepted connection
* is handled in its own pthread (detached). A semaphore-style counter caps
* concurrent in-flight connections at HTTP_MAX_CONNS (64). When the cap is
* reached, accept() blocks until a worker exits. This prevents runaway
* thread creation under high load.
*
* Handler dispatch: El does not expose first-class function references at
* the runtime layer, so the second argument to http_serve(port, handler) is
* treated as a string name (or any el_val_t — the runtime ignores its
* value and uses the registry). Callers register a C-level handler via
*
* extern void el_runtime_register_handler(const char* name,
* el_val_t (*fn)(el_val_t,
* el_val_t,
* el_val_t));
*
* and select the active handler by calling http_set_handler("name") from
* El, or by setting it directly through the C registry. If no handler is
* registered, the server replies with a 200 carrying a default message so
* the loop is observable.
*/
/* ── JSON error helper (used by HTTP, PQ, crypto stubs) ─────────────────── */
/* JSON-escape an arbitrary C string into an allocated buffer. */
static char* json_escape_alloc(const char* s) {
if (!s) return el_strdup("");
JsonBuf b; jb_init(&b);
for (const char* p = s; *p; p++) {
unsigned char c = (unsigned char)*p;
switch (c) {
case '"': jb_puts(&b, "\\\""); break;
case '\\': jb_puts(&b, "\\\\"); break;
case '\n': jb_puts(&b, "\\n"); break;
case '\r': jb_puts(&b, "\\r"); break;
case '\t': jb_puts(&b, "\\t"); break;
default:
if (c < 0x20) {
char tmp[8]; snprintf(tmp, sizeof(tmp), "\\u%04x", c);
jb_puts(&b, tmp);
} else jb_putc(&b, (char)c);
}
}
return b.buf;
}
static el_val_t http_error_json(const char* msg) {
char* esc = json_escape_alloc(msg ? msg : "unknown error");
char* buf = el_strbuf(strlen(esc) + 16);
sprintf(buf, "{\"error\":\"%s\"}", esc);
free(esc);
return el_wrap_str(buf);
}
#ifdef HAVE_CURL
/* ── HTTP client write-callback buffer ───────────────────────────────────── */
typedef struct {
char* data;
size_t len;
size_t cap;
} HttpBuf;
static void httpbuf_init(HttpBuf* b) {
b->cap = 1024;
b->len = 0;
b->data = malloc(b->cap);
if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
b->data[0] = '\0';
}
static void httpbuf_append(HttpBuf* b, const void* src, size_t n) {
if (b->len + n + 1 > b->cap) {
while (b->len + n + 1 > b->cap) b->cap *= 2;
b->data = realloc(b->data, b->cap);
if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
}
memcpy(b->data + b->len, src, n);
b->len += n;
b->data[b->len] = '\0';
}
static size_t http_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
size_t n = size * nmemb;
httpbuf_append((HttpBuf*)ud, ptr, n);
return n;
}
/* HTTP timeout (ms) — read once from EL_HTTP_TIMEOUT_MS, default 60000.
* Applied via CURLOPT_TIMEOUT_MS on every libcurl request. */
static long _el_http_timeout_ms = -1;
static long el_http_timeout_ms(void) {
long v = __atomic_load_n(&_el_http_timeout_ms, __ATOMIC_ACQUIRE);
if (v >= 0) return v;
const char* s = getenv("EL_HTTP_TIMEOUT_MS");
long parsed = 60000L;
if (s && *s) {
char* end = NULL;
long n = strtol(s, &end, 10);
if (end != s && n > 0) parsed = n;
}
__atomic_store_n(&_el_http_timeout_ms, parsed, __ATOMIC_RELEASE);
return parsed;
}
/* Internal: do a libcurl request; takes optional body/headers, optional method override. */
static el_val_t http_do(const char* method, const char* url, const char* body,
struct curl_slist* extra_headers) {
if (!url || !*url) return http_error_json("empty url");
CURL* c = curl_easy_init();
if (!c) return http_error_json("curl_easy_init failed");
HttpBuf rb; httpbuf_init(&rb);
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);
if (method && strcmp(method, "POST") == 0) {
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
} else if (method && strcmp(method, "DELETE") == 0) {
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
}
CURLcode rc = curl_easy_perform(c);
curl_easy_cleanup(c);
if (rc != CURLE_OK) {
free(rb.data);
const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
return http_error_json(m);
}
return el_wrap_str(rb.data);
}
el_val_t http_get(el_val_t url) {
return http_do("GET", EL_CSTR(url), NULL, NULL);
}
el_val_t http_post(el_val_t url, el_val_t body) {
return http_do("POST", EL_CSTR(url), EL_CSTR(body), NULL);
}
el_val_t http_post_json(el_val_t url, el_val_t json_body) {
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(json_body), h);
curl_slist_free_all(h);
return r;
}
/* Build a curl_slist from an ElMap of name -> value strings. */
static struct curl_slist* headers_from_map(el_val_t headers_map) {
struct curl_slist* h = NULL;
ElMap* m = as_map(headers_map);
if (!m) return NULL;
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
const char* v = EL_CSTR(m->values[i]);
if (!k || !v) continue;
size_t n = strlen(k) + strlen(v) + 4;
char* line = malloc(n);
if (!line) continue;
snprintf(line, n, "%s: %s", k, v);
h = curl_slist_append(h, line);
free(line);
}
return h;
}
el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map) {
struct curl_slist* h = headers_from_map(headers_map);
el_val_t r = http_do("GET", EL_CSTR(url), NULL, h);
if (h) curl_slist_free_all(h);
return r;
}
el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map) {
struct curl_slist* h = headers_from_map(headers_map);
el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(body), h);
if (h) curl_slist_free_all(h);
return r;
}
/* http_post_json_with_headers — POST with Content-Type: application/json plus
* any additional headers supplied as an El map. Combines http_post_json and
* http_post_with_headers: the Content-Type header is always prepended so
* callers do not have to include it in their map. */
el_val_t http_post_json_with_headers(el_val_t url, el_val_t headers_map, el_val_t json_body) {
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
/* Append caller-supplied headers from the map */
ElMap* m = as_map(headers_map);
if (m) {
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
const char* v = EL_CSTR(m->values[i]);
if (!k || !v) continue;
size_t n = strlen(k) + strlen(v) + 4;
char* line = malloc(n);
if (!line) continue;
snprintf(line, n, "%s: %s", k, v);
h = curl_slist_append(h, line);
free(line);
}
}
el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(json_body), h);
curl_slist_free_all(h);
return r;
}
el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header) {
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/x-www-form-urlencoded");
const char* a = EL_CSTR(auth_header);
if (a && *a) {
size_t n = strlen(a) + 32;
char* line = malloc(n);
snprintf(line, n, "Authorization: %s", a);
h = curl_slist_append(h, line);
free(line);
}
el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(form_body), h);
curl_slist_free_all(h);
return r;
}
/* HTTP DELETE — mirrors http_post but with CURLOPT_CUSTOMREQUEST=DELETE.
* Returns response body on success; on transport failure returns an error
* JSON fragment (same convention as http_get/http_post). Callers that
* expect "" on failure should check for a leading '{' and an "error" key. */
el_val_t http_delete(el_val_t url) {
return http_do("DELETE", EL_CSTR(url), NULL, NULL);
}
/* ── HTTP → file streaming ────────────────────────────────────────────────
*
* Why this exists: el_val_t strings are NUL-terminated by convention, so
* accumulating an HTTP response into an httpbuf and then wrapping its
* `.data` pointer with el_wrap_str() loses the byte length. Any consumer
* that does strlen() on the wrapped pointer truncates the body at the
* first embedded NUL. Audio (MP3, WAV, OGG), images (PNG, JPEG), and any
* other binary payload hits this. The vessels that download such bodies
* (e.g. ElevenLabs TTS → MP3) get silently corrupted files.
*
* The fix: wire libcurl's CURLOPT_WRITEFUNCTION directly to fwrite()
* against a fopen()-ed FILE*. The bytes never pass through an el_val_t
* string, so embedded NULs are preserved verbatim. Caller's contract is
* just "a file at this path with the response body in it". */
static size_t http_file_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
FILE* f = (FILE*)ud;
return fwrite(ptr, size, nmemb, f);
}
/* Internal: stream body to file. method is "GET" or "POST". body may be NULL
* (GET) or NUL-terminated (POST). headers may be NULL. Returns 1/0. */
static el_val_t http_do_to_file(const char* method, const char* url,
const char* body, struct curl_slist* extra_headers,
const char* output_path) {
if (!url || !*url) return 0;
if (!output_path || !*output_path) return 0;
FILE* f = fopen(output_path, "wb");
if (!f) return 0;
CURL* c = curl_easy_init();
if (!c) { fclose(f); remove(output_path); return 0; }
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_file_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, f);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
curl_easy_setopt(c, CURLOPT_FAILONERROR, 1L); /* 4xx/5xx → CURLE_HTTP_RETURNED_ERROR */
if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);
if (method && strcmp(method, "POST") == 0) {
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
/* For the request body we still rely on strlen — POST bodies are
* caller-controlled and JSON/text in every known El use case.
* If a future caller needs a binary POST body, add a *_bytes
* variant that takes an explicit length, mirroring fs_write_bytes. */
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
}
CURLcode rc = curl_easy_perform(c);
curl_easy_cleanup(c);
/* Flush + close before signalling success, so the file is fully on disk
* by the time the caller reads back. */
int flush_ok = (fflush(f) == 0);
int close_ok = (fclose(f) == 0);
if (rc != CURLE_OK || !flush_ok || !close_ok) {
remove(output_path);
return 0;
}
return 1;
}
el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_path) {
struct curl_slist* h = headers_from_map(headers_map);
el_val_t r = http_do_to_file("GET", EL_CSTR(url), NULL, h, EL_CSTR(output_path));
if (h) curl_slist_free_all(h);
return r;
}
el_val_t http_post_to_file(el_val_t url, el_val_t body, el_val_t headers_map, el_val_t output_path) {
struct curl_slist* h = headers_from_map(headers_map);
el_val_t r = http_do_to_file("POST", EL_CSTR(url), EL_CSTR(body), h, EL_CSTR(output_path));
if (h) curl_slist_free_all(h);
return r;
}
#endif /* HAVE_CURL */
/* ── HTTP server (POSIX sockets + pthreads) ──────────────────────────────── */
#define HTTP_MAX_CONNS 64
typedef el_val_t (*http_handler_fn)(el_val_t method, el_val_t path, el_val_t body);
typedef struct {
char* name;
http_handler_fn fn;
} HttpHandlerEntry;
static HttpHandlerEntry _http_handlers[32];
static size_t _http_handler_count = 0;
static char* _http_active_handler = NULL;
static pthread_mutex_t _http_handler_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_mutex_t _http_conn_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t _http_conn_cv = PTHREAD_COND_INITIALIZER;
static int _http_conn_active = 0;
/* Public C-level API: register a handler by name. Programs that want El
* `http_serve` to dispatch into their handler call this from main() before
* http_serve. Not declared in the header to keep the public API minimal —
* extern lookup works since C symbols are global. */
void el_runtime_register_handler(const char* name, http_handler_fn fn);
void el_runtime_register_handler(const char* name, http_handler_fn fn) {
if (!name || !fn) return;
pthread_mutex_lock(&_http_handler_mu);
for (size_t i = 0; i < _http_handler_count; i++) {
if (strcmp(_http_handlers[i].name, name) == 0) {
_http_handlers[i].fn = fn;
pthread_mutex_unlock(&_http_handler_mu);
return;
}
}
if (_http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
_http_handlers[_http_handler_count].name = el_strdup(name);
_http_handlers[_http_handler_count].fn = fn;
_http_handler_count++;
}
pthread_mutex_unlock(&_http_handler_mu);
}
el_val_t http_set_handler(el_val_t name) {
const char* n = EL_CSTR(name);
pthread_mutex_lock(&_http_handler_mu);
free(_http_active_handler);
_http_active_handler = el_strdup(n ? n : "");
/* If the name is not yet in the registry, try dlsym lookup against
* the running binary's symbol table. Every El `fn name(...)` compiles
* to a global C symbol with that exact name, so El programs can self-
* register their own handlers just by calling http_set_handler("name"). */
if (n && *n) {
int found = 0;
for (size_t i = 0; i < _http_handler_count; i++) {
if (strcmp(_http_handlers[i].name, n) == 0) { found = 1; break; }
}
if (!found) {
void* sym = dlsym(RTLD_DEFAULT, n);
if (sym && _http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
_http_handlers[_http_handler_count].name = el_strdup(n);
_http_handlers[_http_handler_count].fn = (http_handler_fn)sym;
_http_handler_count++;
}
}
}
pthread_mutex_unlock(&_http_handler_mu);
return 0;
}
static http_handler_fn http_lookup_active(void) {
http_handler_fn out = NULL;
pthread_mutex_lock(&_http_handler_mu);
if (_http_active_handler) {
for (size_t i = 0; i < _http_handler_count; i++) {
if (strcmp(_http_handlers[i].name, _http_active_handler) == 0) {
out = _http_handlers[i].fn; break;
}
}
}
pthread_mutex_unlock(&_http_handler_mu);
return out;
}
/* Auto-detect Content-Type from response body. */
static const char* http_detect_content_type(const char* body) {
if (!body) return "text/plain; charset=utf-8";
const char* p = body;
/* Binary magic bytes — check before stripping whitespace */
if ((unsigned char)p[0] == 0x89 && p[1]=='P' && p[2]=='N' && p[3]=='G')
return "image/png";
if ((unsigned char)p[0] == 0xFF && (unsigned char)p[1] == 0xD8)
return "image/jpeg";
if (strncmp(p, "GIF8", 4) == 0) return "image/gif";
if (strncmp(p, "RIFF", 4) == 0) return "image/webp";
if (strncmp(p, "wOFF", 4) == 0) return "font/woff";
if (strncmp(p, "wOF2", 4) == 0) return "font/woff2";
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (strncasecmp(p, "<!DOCTYPE", 9) == 0) return "text/html; charset=utf-8";
if (strncasecmp(p, "<html", 5) == 0) return "text/html; charset=utf-8";
if (strncasecmp(p, "<svg", 4) == 0) return "image/svg+xml";
if (*p == '{' || *p == '[') return "application/json; charset=utf-8";
return "text/plain; charset=utf-8";
}
/* Read the full HTTP request from a connection: request line, headers, body.
*
* `out_headers_block` is optional (may be NULL). When non-NULL, on success
* it receives an allocated NUL-terminated copy of the raw header block —
* everything between the request line's trailing CRLF and the final \r\n\r\n
* (no leading/trailing CRLFs). The 3-arg http_serve worker passes NULL here
* (no headers needed). The 4-arg http_serve_v2 worker passes a real pointer
* so it can build a header map for the El handler. */
static int http_read_request(int fd, char** out_method, char** out_path,
char** out_body, char** out_headers_block) {
*out_method = NULL; *out_path = NULL; *out_body = NULL;
if (out_headers_block) *out_headers_block = NULL;
/* Read headers until \r\n\r\n */
size_t cap = 4096, len = 0;
char* buf = malloc(cap);
if (!buf) return -1;
while (1) {
if (len + 1 >= cap) {
if (cap >= 1024 * 1024) { free(buf); return -1; }
cap *= 2;
buf = realloc(buf, cap);
if (!buf) return -1;
}
ssize_t n = recv(fd, buf + len, cap - len - 1, 0);
if (n <= 0) { free(buf); return -1; }
len += (size_t)n;
buf[len] = '\0';
if (strstr(buf, "\r\n\r\n")) break;
}
/* Parse request line */
char* sp1 = strchr(buf, ' ');
if (!sp1) { free(buf); return -1; }
*sp1 = '\0';
*out_method = el_strdup(buf);
char* path_start = sp1 + 1;
char* sp2 = strchr(path_start, ' ');
if (!sp2) { free(*out_method); *out_method = NULL; free(buf); return -1; }
*sp2 = '\0';
*out_path = el_strdup(path_start);
char* hdr_end = strstr(sp2 + 1, "\r\n\r\n");
/* Capture the raw header block (after the request line's CRLF, up to
* but not including the terminating \r\n\r\n) for callers that asked
* for it. The legacy 3-arg path passes NULL and skips this. */
if (out_headers_block) {
char* hdr_start = strstr(sp2 + 1, "\r\n");
if (hdr_start && hdr_start < hdr_end) {
hdr_start += 2;
size_t hb_len = (size_t)(hdr_end - hdr_start);
char* hb = malloc(hb_len + 1);
if (hb) {
memcpy(hb, hdr_start, hb_len);
hb[hb_len] = '\0';
*out_headers_block = hb;
}
} else {
*out_headers_block = el_strdup("");
}
}
/* Find Content-Length */
long content_length = 0;
char* hp = sp2 + 1;
while (hp < hdr_end) {
char* line_end = strstr(hp, "\r\n");
/* line_end == hdr_end means we're on the LAST header line — its
* trailing \r\n is the same \r\n that begins the \r\n\r\n header
* terminator. Process this line; only stop when line_end is past
* hdr_end (which means the parser walked off the end of the
* header block). The previous condition (line_end >= hdr_end)
* silently dropped any Content-Length that appeared as the last
* header — exactly what real curl/clients tend to emit. */
if (!line_end || line_end > hdr_end) break;
if (strncasecmp(hp, "Content-Length:", 15) == 0) {
content_length = strtol(hp + 15, NULL, 10);
if (content_length < 0) content_length = 0;
if (content_length > 64 * 1024 * 1024) content_length = 64 * 1024 * 1024;
}
hp = line_end + 2;
}
/* Body: any bytes already read past hdr_end, plus more recv */
char* body_start = hdr_end + 4;
size_t body_have = (buf + len) - body_start;
char* body = malloc((size_t)content_length + 1);
if (!body) { free(*out_method); free(*out_path); *out_method=NULL; *out_path=NULL; free(buf); return -1; }
if ((long)body_have > content_length) body_have = (size_t)content_length;
if (body_have > 0) memcpy(body, body_start, body_have);
while ((long)body_have < content_length) {
ssize_t n = recv(fd, body + body_have, (size_t)content_length - body_have, 0);
if (n <= 0) break;
body_have += (size_t)n;
}
body[body_have] = '\0';
*out_body = body;
free(buf);
return 0;
}
/* Reason phrase for common HTTP statuses. Falls back to "Status" for the
* long tail — clients only care about the numeric code. */
static const char* http_reason_phrase(int status) {
switch (status) {
case 200: return "OK";
case 201: return "Created";
case 202: return "Accepted";
case 204: return "No Content";
case 301: return "Moved Permanently";
case 302: return "Found";
case 303: return "See Other";
case 304: return "Not Modified";
case 307: return "Temporary Redirect";
case 308: return "Permanent Redirect";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 409: return "Conflict";
case 410: return "Gone";
case 422: return "Unprocessable Entity";
case 429: return "Too Many Requests";
case 500: return "Internal Server Error";
case 501: return "Not Implemented";
case 502: return "Bad Gateway";
case 503: return "Service Unavailable";
case 504: return "Gateway Timeout";
default: return "Status";
}
}
/* Best-effort send with retry on partial writes. */
static int http_send_all(int fd, const char* p, size_t left) {
while (left > 0) {
ssize_t w = send(fd, p, left, 0);
if (w <= 0) return -1;
p += w; left -= (size_t)w;
}
return 0;
}
/* Discriminator that http_response() embeds at the start of its envelope.
* A handler returning a string starting with this exact prefix is treated
* as a structured response; anything else is treated as a raw body. */
#define EL_HTTP_RESPONSE_TAG "{\"el_http_response\":1"
/* Keys that conflict with runtime-managed headers are silently dropped to
* avoid double-emission — the runtime always emits its own Content-Length
* and Connection: close. Content-Type from the envelope IS allowed and
* overrides auto-detection. */
static int http_header_is_managed(const char* k) {
return strcasecmp(k, "Content-Length") == 0
|| strcasecmp(k, "Connection") == 0;
}
/* Walk an ElMap of header pairs and emit each as `K: V\r\n` into JsonBuf b.
* Sets *out_saw_content_type to 1 if the map contained an explicit
* Content-Type so the caller can skip auto-detection. */
static void http_emit_headers_from_map(JsonBuf* b, el_val_t headers_map,
int* out_saw_content_type) {
*out_saw_content_type = 0;
if (headers_map == 0) return;
ElMap* m = (ElMap*)(uintptr_t)headers_map;
if (!m || m->hdr.magic != EL_MAGIC_MAP) return;
for (int64_t i = 0; i < m->count; i++) {
const char* k = EL_CSTR(m->keys[i]);
const char* v = EL_CSTR(m->values[i]);
if (!k || !v) continue;
if (http_header_is_managed(k)) continue;
if (strcasecmp(k, "Content-Type") == 0) *out_saw_content_type = 1;
jb_puts(b, k);
jb_puts(b, ": ");
jb_puts(b, v);
jb_puts(b, "\r\n");
}
}
/* Parse the envelope produced by http_response(). On success returns 1 and
* populates *out_status, *out_headers_map (an ElMap el_val_t — caller must
* el_release via out_parsed_root), and *out_body (malloc'd, caller frees).
* On failure returns 0.
*
* Implementation: manual field scanner — does NOT run json_parse on the full
* envelope. Running the recursive-descent JSON parser on a 4050 KB envelope
* (common when the body contains minified/obfuscated JavaScript) fails because
* the parser allocates intermediate ElMap nodes for the whole structure.
* Instead we scan directly:
* • "status" — strtol scan
* • "headers" — brace-depth scan to extract the object literal, then
* json_parse only that small substring (always < 1 KB)
* • "body" — jp_parse_string_raw to unescape the JSON string in one pass,
* without building any intermediate data structures */
static int http_parse_envelope(const char* s, int* out_status,
el_val_t* out_headers_map, char** out_body,
el_val_t* out_parsed_root) {
if (!s) return 0;
if (strncmp(s, EL_HTTP_RESPONSE_TAG,
sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;
/* ── status ──────────────────────────────────────────────────────────── */
int status = 200;
{
const char* sp = strstr(s, "\"status\":");
if (sp) {
const char* np = sp + 9;
while (*np == ' ' || *np == '\t') np++;
long sc = strtol(np, NULL, 10);
if (sc >= 100 && sc <= 599) status = (int)sc;
}
}
/* ── headers ─────────────────────────────────────────────────────────── */
el_val_t hmap = 0;
el_val_t parsed_hdrs = EL_NULL;
{
const char* hp = strstr(s, "\"headers\":");
if (hp) {
hp += 10;
while (*hp == ' ' || *hp == '\t') hp++;
if (*hp == '{') {
/* Scan for matching '}', honouring nested objects and strings */
const char* hobj_start = hp;
const char* cp = hp + 1;
int depth = 1, in_str = 0;
while (*cp && depth > 0) {
if (in_str) {
if (*cp == '\\' && *(cp + 1)) { cp += 2; continue; }
if (*cp == '"') in_str = 0;
} else {
if (*cp == '"') in_str = 1;
else if (*cp == '{') depth++;
else if (*cp == '}') { if (--depth == 0) break; }
}
cp++;
}
if (depth == 0) {
/* cp points at the closing '}'; extract the object literal */
size_t hlen = (size_t)(cp - hobj_start + 1);
char* hobj = malloc(hlen + 1);
if (hobj) {
memcpy(hobj, hobj_start, hlen);
hobj[hlen] = '\0';
/* Headers are always simple k/v string pairs — json_parse
* is safe on this small substring (typically < 1 KB). */
parsed_hdrs = json_parse(EL_STR(hobj));
free(hobj);
if (parsed_hdrs != EL_NULL) {
ElMap* hm = (ElMap*)(uintptr_t)parsed_hdrs;
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = parsed_hdrs;
}
}
}
}
}
}
/* ── body ────────────────────────────────────────────────────────────── */
/* Search forward so we don't accidentally match "body": inside a header
* value. http_response() always appends the body field last. */
char* body = NULL;
{
const char* bp = strstr(s, "\"body\":");
if (bp) {
bp += 7;
while (*bp == ' ' || *bp == '\t') bp++;
if (*bp == '"') {
/* jp_parse_string_raw unescapes a JSON string in one pass,
* producing a plain malloc'd C string. Caller frees it. */
JsonParser jp = { .p = bp, .end = bp + strlen(bp), .err = 0 };
char* parsed = jp_parse_string_raw(&jp);
if (!jp.err) {
body = parsed;
} else {
free(parsed);
}
}
}
if (!body) body = strdup("");
}
*out_status = status;
*out_headers_map = hmap;
*out_body = body;
*out_parsed_root = parsed_hdrs; /* caller el_release()s to free hmap */
return 1;
}
/* Lightweight `__status__` envelope: if the body's first key is `__status__`
* and its value is a numeric literal, lift the status to the HTTP layer and
* strip the marker from the body before sending. This is the common case for
* El handlers that want to return 4xx/5xx without going through
* http_response() — they just prepend `{"__status__":<int>,...}` to the JSON
* they were already returning.
*
* We deliberately recognise ONLY the first-key form so the contract is cheap
* to detect and unambiguous: `{"__status__":401,"error":"unauthorized"}` is
* an envelope, but `{"error":"...","__status__":401}` is not. Product code
* controls placement.
*
* On success returns 1 with *out_status set and *out_body_alloc populated
* with a freshly malloc'd body (caller frees). On failure returns 0 and
* leaves outputs untouched. */
static int http_parse_status_envelope(const char* s, int* out_status,
char** out_body_alloc) {
if (!s) return 0;
const char* p = s;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return 0;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
static const char marker[] = "\"__status__\"";
size_t mlen = sizeof(marker) - 1;
if (strncmp(p, marker, mlen) != 0) return 0;
p += mlen;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return 0;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p < '0' || *p > '9') return 0; /* non-numeric -> not an envelope */
int status = 0;
while (*p >= '0' && *p <= '9') {
status = status * 10 + (*p - '0');
p++;
}
if (status < 100 || status > 599) return 0;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
/* Two trailing shapes accepted:
* ,"k":v,...} -> body becomes {"k":v,...}
* } -> body becomes {}
* Anything else (e.g. `:` re-appearing, garbage) drops the envelope so
* we don't strip what we shouldn't. */
if (*p == '}') {
*out_status = status;
*out_body_alloc = el_strdup("{}");
return 1;
}
if (*p != ',') return 0;
p++; /* skip the comma; the rest of the object follows */
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
/* Build the trimmed body: '{' + remainder. */
size_t rest_len = strlen(p);
char* out = (char*)malloc(rest_len + 2);
if (!out) return 0;
out[0] = '{';
memcpy(out + 1, p, rest_len);
out[rest_len + 1] = '\0';
*out_status = status;
*out_body_alloc = out;
return 1;
}
/* Send a fully-built HTTP response. If `body` starts with the envelope tag,
* unpack status/headers/body. Otherwise emit the historical 200-OK with
* auto-detected Content-Type. */
/* Thread-local flag: if 1, http_send_response writes status + headers but
* NO body (HEAD method behaviour). Set by http_worker before calling
* http_send_response, cleared after. */
static __thread int _tl_http_head_only = 0;
static void http_send_response(int fd, const char* body) {
if (!body) body = "";
int status = 200;
el_val_t env_headers_map = 0;
char* env_body = NULL;
el_val_t env_parsed_root = 0;
int is_envelope = http_parse_envelope(body, &status,
&env_headers_map, &env_body,
&env_parsed_root);
/* If the rich http_response() envelope didn't claim this body, try the
* lightweight `__status__` form. This second envelope is malloc-backed so
* we route it through env_body and let the existing cleanup path free it
* — same lifetime contract, no special case at the bottom of the
* function. */
if (!is_envelope) {
char* trimmed = NULL;
if (http_parse_status_envelope(body, &status, &trimmed)) {
env_body = trimmed;
is_envelope = 1;
}
}
const char* eff_body = is_envelope ? env_body : body;
int binary_side_channel = 0;
/* Binary side-channel: if the envelope body is the sentinel "__el_binary__",
* http_response() stored the real bytes in _tl_binary_body/_tl_binary_size.
* Substitute them here so http_send_all() sends the correct binary payload. */
if (is_envelope && env_body && strcmp(env_body, "__el_binary__") == 0
&& _tl_binary_body && _tl_binary_size > 0) {
eff_body = _tl_binary_body;
binary_side_channel = 1;
}
/* Use the real byte count from fs_read if available (handles binary files
* with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for
* normal text/JSON responses where _tl_fs_read_len is 0. */
size_t blen = binary_side_channel ? _tl_binary_size
: (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
_tl_fs_read_len = 0; /* consume — one-shot per response */
int head_only = _tl_http_head_only;
JsonBuf hdrs; jb_init(&hdrs);
int saw_content_type = 0;
if (is_envelope) {
http_emit_headers_from_map(&hdrs, env_headers_map,
&saw_content_type);
}
if (!saw_content_type) {
jb_puts(&hdrs, "Content-Type: ");
jb_puts(&hdrs, http_detect_content_type(eff_body));
jb_puts(&hdrs, "\r\n");
}
char status_line[64];
int sl = snprintf(status_line, sizeof(status_line),
"HTTP/1.1 %d %s\r\n",
status, http_reason_phrase(status));
if (sl < 0) {
if (env_parsed_root) el_release(env_parsed_root);
free(env_body); free(hdrs.buf); return;
}
char tail[128];
int tl = snprintf(tail, sizeof(tail),
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n", blen);
if (tl < 0) {
if (env_parsed_root) el_release(env_parsed_root);
free(env_body); free(hdrs.buf); return;
}
if (http_send_all(fd, status_line, (size_t)sl) == 0
&& http_send_all(fd, hdrs.buf, hdrs.len) == 0
&& http_send_all(fd, tail, (size_t)tl) == 0
&& (head_only
/* HEAD requests echo headers + Content-Length but no body. */
? 1
: http_send_all(fd, eff_body, blen) == 0)) {
/* sent successfully */
}
if (env_parsed_root) el_release(env_parsed_root);
free(env_body);
free(hdrs.buf);
/* Release binary side-channel if it was used (or left over from an error). */
if (_tl_binary_body) {
free(_tl_binary_body);
_tl_binary_body = NULL;
_tl_binary_size = 0;
}
}
typedef struct {
int fd;
} HttpWorkerArg;
static void* http_worker(void* arg) {
HttpWorkerArg* a = (HttpWorkerArg*)arg;
int fd = a->fd;
free(a);
char *method = NULL, *path = NULL, *body = NULL;
if (http_read_request(fd, &method, &path, &body, NULL) == 0) {
http_handler_fn h = http_lookup_active();
char* response = NULL;
/* HEAD: dispatch as GET so existing handlers respond with the same
* body, but flag the response writer to emit headers only. RFC 9110
* requires HEAD to mirror GET headers + Content-Length without body. */
int head_only = (method && strcmp(method, "HEAD") == 0);
const char* dispatch_method = head_only ? "GET" : method;
el_request_start(); /* begin per-request arena */
if (h) {
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), EL_STR(body));
const char* rs = EL_CSTR(r);
/* Copy response out BEFORE arena teardown.
* For binary files, _tl_fs_read_len holds the real byte count —
* use memcpy instead of strdup so null bytes are preserved. */
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
} else {
response = el_strdup_persist("el-runtime: no http handler registered");
}
el_request_end(); /* free all intermediate strings */
_tl_http_head_only = head_only;
http_send_response(fd, response);
_tl_http_head_only = 0;
free(response);
}
free(method); free(path); free(body);
close(fd);
/* release a slot */
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
pthread_mutex_unlock(&_http_conn_mu);
return NULL;
}
el_val_t http_serve(el_val_t port, el_val_t handler) {
/* If `handler` looks like a string name, register it as the active handler. */
const char* hname = EL_CSTR(handler);
if (hname && looks_like_string(handler)) {
http_set_handler(handler);
}
int p = (int)port;
if (p <= 0 || p > 65535) { fprintf(stderr, "http_serve: invalid port %d\n", p); return 0; }
/* Dual-stack: AF_INET6 with IPV6_V6ONLY=0 accepts both IPv4 and IPv6.
* This makes `localhost` work in browsers that resolve it to ::1 first. */
int sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return 0; }
int yes = 1; int no = 0;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons((uint16_t)p);
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(sock); return 0;
}
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return 0; }
fprintf(stderr, "[http] listening on [::]:%d (dual-stack)\n", p);
while (1) {
struct sockaddr_in6 cli;
socklen_t clen = sizeof(cli);
int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
if (cfd < 0) {
if (errno == EINTR) continue;
perror("accept"); break;
}
pthread_mutex_lock(&_http_conn_mu);
while (_http_conn_active >= HTTP_MAX_CONNS) {
pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
}
_http_conn_active++;
pthread_mutex_unlock(&_http_conn_mu);
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
if (!arg) { close(cfd); continue; }
arg->fd = cfd;
pthread_t tid;
if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
close(cfd); free(arg);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
pthread_mutex_unlock(&_http_conn_mu);
continue;
}
pthread_detach(tid);
}
close(sock);
return 0;
}
/* ── HTTP server v2 — request headers + structured response ──────────────── */
/*
* v2 widens the handler signature from
* (method, path, body) -> body_string
* to
* (method, path, headers_map, body) -> body_string_or_envelope
*
* The response envelope is detected uniformly inside http_send_response — so
* 4-arg handlers can return either a plain body or http_response(...). The
* 3-arg path stays untouched in spirit (its handlers still build plain
* bodies; the envelope tag, being `{"el_http_response":1`, will never
* collide with normal JSON the legacy server.el routes return).
*
* Registry is parallel to the 3-arg handler registry: separate name table,
* separate active-handler slot, separate dlsym fallback. Mixing v1 and v2
* handlers in the same process is fine — they don't share the active slot. */
typedef el_val_t (*http_handler4_fn)(el_val_t method, el_val_t path,
el_val_t headers_map, el_val_t body);
typedef struct {
char* name;
http_handler4_fn fn;
} HttpHandler4Entry;
static HttpHandler4Entry _http_handlers4[32];
static size_t _http_handler4_count = 0;
static char* _http_active_handler4 = NULL;
void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn);
void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn) {
if (!name || !fn) return;
pthread_mutex_lock(&_http_handler_mu);
for (size_t i = 0; i < _http_handler4_count; i++) {
if (strcmp(_http_handlers4[i].name, name) == 0) {
_http_handlers4[i].fn = fn;
pthread_mutex_unlock(&_http_handler_mu);
return;
}
}
if (_http_handler4_count <
sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
_http_handlers4[_http_handler4_count].name = el_strdup(name);
_http_handlers4[_http_handler4_count].fn = fn;
_http_handler4_count++;
}
pthread_mutex_unlock(&_http_handler_mu);
}
el_val_t http_set_handler_v2(el_val_t name) {
const char* n = EL_CSTR(name);
pthread_mutex_lock(&_http_handler_mu);
free(_http_active_handler4);
_http_active_handler4 = el_strdup(n ? n : "");
if (n && *n) {
int found = 0;
for (size_t i = 0; i < _http_handler4_count; i++) {
if (strcmp(_http_handlers4[i].name, n) == 0) { found = 1; break; }
}
if (!found) {
void* sym = dlsym(RTLD_DEFAULT, n);
if (sym && _http_handler4_count <
sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
_http_handlers4[_http_handler4_count].name = el_strdup(n);
_http_handlers4[_http_handler4_count].fn =
(http_handler4_fn)sym;
_http_handler4_count++;
}
}
}
pthread_mutex_unlock(&_http_handler_mu);
return 0;
}
static http_handler4_fn http_lookup_active_v2(void) {
http_handler4_fn out = NULL;
pthread_mutex_lock(&_http_handler_mu);
if (_http_active_handler4) {
for (size_t i = 0; i < _http_handler4_count; i++) {
if (strcmp(_http_handlers4[i].name,
_http_active_handler4) == 0) {
out = _http_handlers4[i].fn; break;
}
}
}
pthread_mutex_unlock(&_http_handler_mu);
return out;
}
/* Build an ElMap from the raw header block produced by http_read_request.
* Keys are lowercased (RFC 7230 — case-insensitive); values have leading
* whitespace trimmed. Repeated headers with the same name are joined with
* ", " in arrival order, matching standard library behaviour elsewhere. */
static el_val_t http_build_headers_map(const char* hdr_block) {
el_val_t m = el_map_new(0);
if (!hdr_block || !*hdr_block) return m;
const char* p = hdr_block;
while (*p) {
const char* line_end = strstr(p, "\r\n");
const char* end = line_end ? line_end : p + strlen(p);
const char* colon = NULL;
for (const char* c = p; c < end; c++) {
if (*c == ':') { colon = c; break; }
}
if (colon && colon > p) {
size_t klen = (size_t)(colon - p);
char* key = malloc(klen + 1);
if (key) {
for (size_t i = 0; i < klen; i++) {
unsigned char ch = (unsigned char)p[i];
key[i] = (char)tolower(ch);
}
key[klen] = '\0';
const char* vstart = colon + 1;
while (vstart < end && (*vstart == ' ' || *vstart == '\t')) vstart++;
size_t vlen = (size_t)(end - vstart);
/* Strip trailing OWS just in case. */
while (vlen > 0
&& (vstart[vlen - 1] == ' '
|| vstart[vlen - 1] == '\t')) vlen--;
/* Coalesce repeats: if key already present, append ", value". */
el_val_t existing = el_map_get(m, EL_STR(key));
if (existing != 0 && looks_like_string(existing)) {
const char* old = EL_CSTR(existing);
size_t olen = strlen(old);
char* combined = malloc(olen + 2 + vlen + 1);
if (combined) {
memcpy(combined, old, olen);
memcpy(combined + olen, ", ", 2);
memcpy(combined + olen + 2, vstart, vlen);
combined[olen + 2 + vlen] = '\0';
m = el_map_set(m, EL_STR(key), EL_STR(combined));
}
free(key);
} else {
char* val = malloc(vlen + 1);
if (val) {
memcpy(val, vstart, vlen);
val[vlen] = '\0';
m = el_map_set(m, EL_STR(key), EL_STR(val));
} else {
free(key);
}
}
}
}
if (!line_end) break;
p = line_end + 2;
}
return m;
}
static void* http_worker_v2(void* arg) {
HttpWorkerArg* a = (HttpWorkerArg*)arg;
int fd = a->fd;
free(a);
char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
http_handler4_fn h = http_lookup_active_v2();
char* response = NULL;
int head_only = (method && strcmp(method, "HEAD") == 0);
const char* dispatch_method = head_only ? "GET" : method;
el_request_start(); /* begin per-request arena */
if (h) {
el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
const char* rs = EL_CSTR(r);
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
el_release(hmap);
} else {
response = el_strdup_persist(
"el-runtime: no v2 http handler registered "
"(call http_set_handler_v2)");
}
el_request_end(); /* free all intermediate strings */
_tl_http_head_only = head_only;
http_send_response(fd, response);
_tl_http_head_only = 0;
free(response);
}
free(method); free(path); free(body); free(hdr_block);
close(fd);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
pthread_mutex_unlock(&_http_conn_mu);
return NULL;
}
el_val_t http_serve_v2(el_val_t port, el_val_t handler) {
const char* hname = EL_CSTR(handler);
if (hname && looks_like_string(handler)) {
http_set_handler_v2(handler);
}
int p = (int)port;
if (p <= 0 || p > 65535) {
fprintf(stderr, "http_serve_v2: invalid port %d\n", p);
return 0;
}
/* Dual-stack: same as http_serve - AF_INET6 + IPV6_V6ONLY=0. */
int sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return 0; }
int yes = 1; int no = 0;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons((uint16_t)p);
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(sock); return 0;
}
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return 0; }
fprintf(stderr, "[http v2] listening on [::]:%d (dual-stack)\n", p);
while (1) {
struct sockaddr_in6 cli;
socklen_t clen = sizeof(cli);
int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
if (cfd < 0) {
if (errno == EINTR) continue;
perror("accept"); break;
}
pthread_mutex_lock(&_http_conn_mu);
while (_http_conn_active >= HTTP_MAX_CONNS) {
pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
}
_http_conn_active++;
pthread_mutex_unlock(&_http_conn_mu);
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
if (!arg) { close(cfd); continue; }
arg->fd = cfd;
pthread_t tid;
if (pthread_create(&tid, NULL, http_worker_v2, arg) != 0) {
close(cfd); free(arg);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
pthread_mutex_unlock(&_http_conn_mu);
continue;
}
pthread_detach(tid);
}
close(sock);
return 0;
}
/* Build the response envelope a 4-arg handler can return. We hand-write
* the JSON so the discriminator key always lands first — the runtime's
* http_parse_envelope() detects it via prefix match. headers_json must be
* either "" (empty), "{}" (empty object), or a well-formed JSON object
* literal; anything else will produce a malformed envelope and the runtime
* will treat the whole string as a plain body (no envelope detected). */
el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
long sc = (long)status;
if (sc < 100 || sc > 599) sc = 200;
const char* hj = EL_CSTR(headers_json);
if (!hj || !*hj) hj = "{}";
/* Light validation: must start with '{' and end with '}'. */
size_t hlen = strlen(hj);
int hj_ok = (hlen >= 2 && hj[0] == '{' && hj[hlen - 1] == '}');
if (!hj_ok) hj = "{}";
const char* b = EL_CSTR(body);
if (!b) b = "";
/* Capture binary length BEFORE clearing _tl_fs_read_len.
* If the body came from fs_read(), _tl_fs_read_len holds the real byte
* count. jb_emit_escaped() stops at the first NUL byte, so we cannot
* JSON-encode binary data directly. Instead we copy it to a thread-local
* side-channel and write the sentinel "__el_binary__" into the envelope.
* http_send_response() detects the sentinel and uses the side-channel. */
size_t binary_len = _tl_fs_read_len;
/* Clear the fs_read binary-length hint: the envelope we're about to build
* is a fresh JSON string, not the raw file bytes. Without this reset,
* http_worker would use the stale _tl_fs_read_len (= original file size)
* to copy the response — truncating the larger envelope before it reaches
* http_send_response and http_parse_envelope. */
_tl_fs_read_len = 0;
if (binary_len > 0) {
/* Binary body path: store raw bytes in thread-local, emit sentinel. */
free(_tl_binary_body); /* discard any stale binary from a prior error path */
_tl_binary_body = malloc(binary_len);
if (_tl_binary_body) {
memcpy(_tl_binary_body, b, binary_len);
_tl_binary_size = binary_len;
} else {
_tl_binary_size = 0; /* malloc failed — fall through to empty body */
}
}
JsonBuf out; jb_init(&out);
jb_puts(&out, EL_HTTP_RESPONSE_TAG); /* {"el_http_response":1 */
jb_puts(&out, ",\"status\":");
char num[32];
snprintf(num, sizeof(num), "%ld", sc);
jb_puts(&out, num);
jb_puts(&out, ",\"headers\":");
jb_puts(&out, hj);
jb_puts(&out, ",\"body\":");
if (binary_len > 0 && _tl_binary_body) {
/* Sentinel: http_send_response() will substitute the real bytes. */
jb_puts(&out, "\"__el_binary__\"");
} else {
jb_emit_escaped(&out, b);
}
jb_putc(&out, '}');
return el_wrap_str(out.buf);
}
/* ── Filesystem ──────────────────────────────────────────────────────────── */
el_val_t fs_read(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
_tl_fs_read_len = 0;
if (!path) return el_wrap_str(el_strdup(""));
FILE* f = fopen(path, "rb");
if (!f) return el_wrap_str(el_strdup(""));
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz < 0) { fclose(f); return el_wrap_str(el_strdup("")); } /* pipe/special file */
char* buf = el_strbuf((size_t)sz);
size_t got = fread(buf, 1, (size_t)sz, f);
buf[got] = '\0';
_tl_fs_read_len = got; /* store real byte count for binary-safe send */
fclose(f);
return el_wrap_str(buf);
}
el_val_t fs_write(el_val_t pathv, el_val_t contentv) {
const char* path = EL_CSTR(pathv);
const char* content = EL_CSTR(contentv);
if (!path || !content) return 0;
FILE* f = fopen(path, "wb");
if (!f) return 0;
size_t n = strlen(content);
size_t written = fwrite(content, 1, n, f);
fclose(f);
return written == n ? 1 : 0;
}
/* fs_write_bytes — explicit-length binary write. Bypasses strlen so embedded
* NULs survive. Caller must know the byte count (e.g. from base64_decode,
* or the fixed 32-byte sha256_bytes/hmac_sha256_bytes outputs).
*
* If `length` is negative, treats as failure. If `length` is 0, creates an
* empty file (still useful as a "touch with content" primitive). */
el_val_t fs_write_bytes(el_val_t pathv, el_val_t bytesv, el_val_t lengthv) {
const char* path = EL_CSTR(pathv);
const char* bytes = EL_CSTR(bytesv);
int64_t n = (int64_t)lengthv;
if (!path || !bytes) return 0;
if (n < 0) return 0;
FILE* f = fopen(path, "wb");
if (!f) return 0;
size_t written = (n > 0) ? fwrite(bytes, 1, (size_t)n, f) : 0;
int flush_ok = (fflush(f) == 0);
int close_ok = (fclose(f) == 0);
if (!flush_ok || !close_ok || written != (size_t)n) {
remove(path);
return 0;
}
return 1;
}
// stdout_to_file / stdout_restore — redirect process stdout to a file and
// restore it. Used by the compiler's JS post-processing pipeline to capture
// codegen output before piping through terser / obfuscator.
#include <fcntl.h>
static int _el_saved_stdout_fd = -1;
el_val_t stdout_to_file(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
if (!path) return (el_val_t)(int64_t)-1;
fflush(stdout);
_el_saved_stdout_fd = dup(STDOUT_FILENO);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) return (el_val_t)(int64_t)-1;
dup2(fd, STDOUT_FILENO);
close(fd);
return (el_val_t)(int64_t)0;
}
el_val_t stdout_restore(void) {
if (_el_saved_stdout_fd >= 0) {
fflush(stdout);
dup2(_el_saved_stdout_fd, STDOUT_FILENO);
close(_el_saved_stdout_fd);
_el_saved_stdout_fd = -1;
}
return (el_val_t)(int64_t)0;
}
// exec_command — run a shell command, return exit code (0 = success).
// Used by elb and other El tooling to invoke subprocesses.
el_val_t exec_command(el_val_t cmdv) {
const char* cmd = EL_CSTR(cmdv);
if (!cmd) return (el_val_t)(int64_t)-1;
int ret = system(cmd);
return (el_val_t)(int64_t)ret;
}
// exec_capture — run a shell command, capture stdout, return as String.
// Returns "" on failure.
el_val_t exec_capture(el_val_t cmdv) {
const char* cmd = EL_CSTR(cmdv);
if (!cmd) return el_wrap_str(el_strdup(""));
FILE* f = popen(cmd, "r");
if (!f) return el_wrap_str(el_strdup(""));
JsonBuf b; jb_init(&b);
char buf[4096];
while (fgets(buf, sizeof(buf), f)) jb_puts(&b, buf);
pclose(f);
return el_wrap_str(b.buf);
}
// exec — run a shell command via /bin/sh, capture stdout, return as String.
// Times out after 30 seconds. Returns "" on any error.
// El name: exec(cmd) -> String
el_val_t exec(el_val_t cmdv) {
const char* cmd = EL_CSTR(cmdv);
if (!cmd || !*cmd) return el_wrap_str(el_strdup(""));
/* Build a time-limited command: wrap with timeout(1) if available,
* otherwise rely on the 30s read loop guard below. We use the simple
* popen approach with a deadline measured by wall clock so the caller
* is never blocked indefinitely. */
FILE* f = popen(cmd, "r");
if (!f) return el_wrap_str(el_strdup(""));
JsonBuf b; jb_init(&b);
char buf[4096];
/* 30-second wall-clock deadline */
time_t deadline = time(NULL) + 30;
while (time(NULL) < deadline) {
if (fgets(buf, sizeof(buf), f) == NULL) break;
jb_puts(&b, buf);
}
pclose(f);
return el_wrap_str(b.buf);
}
// exec_bg — run a shell command in background, return PID as String.
// The child process runs independently; the caller is not blocked.
// Returns "" on fork failure.
// El name: exec_bg(cmd) -> String
el_val_t exec_bg(el_val_t cmdv) {
const char* cmd = EL_CSTR(cmdv);
if (!cmd || !*cmd) return el_wrap_str(el_strdup(""));
pid_t pid = fork();
if (pid < 0) {
/* fork failed */
return el_wrap_str(el_strdup(""));
}
if (pid == 0) {
/* child: detach from parent's stdio, exec via shell */
setsid();
int devnull = open("/dev/null", O_RDWR);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
dup2(devnull, STDOUT_FILENO);
dup2(devnull, STDERR_FILENO);
close(devnull);
}
execl("/bin/sh", "sh", "-c", cmd, (char*)NULL);
_exit(127);
}
/* parent: convert pid to string and return immediately */
char pidbuf[32];
snprintf(pidbuf, sizeof(pidbuf), "%d", (int)pid);
return el_wrap_str(el_strdup(pidbuf));
}
el_val_t fs_list(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
el_val_t lst = el_list_empty();
if (!path) return lst;
DIR* d = opendir(path);
if (!d) return lst;
struct dirent* e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
lst = el_list_append(lst, el_wrap_str(el_strdup(e->d_name)));
}
closedir(d);
return lst;
}
/* fs_list_json — return directory entries as a JSON array of strings.
* Returns "[]" for missing or non-directory paths. Excludes "." and "..". */
el_val_t fs_list_json(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
if (!path) return EL_STR("[]");
DIR* d = opendir(path);
if (!d) return EL_STR("[]");
/* Collect entries first so we can build the JSON in one pass. */
char** names = NULL;
size_t count = 0, cap = 0;
struct dirent* e;
while ((e = readdir(d)) != NULL) {
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
if (count >= cap) {
cap = cap ? cap * 2 : 16;
names = realloc(names, cap * sizeof(char*));
if (!names) { closedir(d); return EL_STR("[]"); }
}
names[count++] = strdup(e->d_name);
}
closedir(d);
/* Build JSON array. */
size_t sz = 3; /* "[]" + NUL */
for (size_t i = 0; i < count; i++) sz += strlen(names[i]) * 2 + 6; /* conservative */
char* buf = malloc(sz);
if (!buf) { for (size_t i = 0; i < count; i++) free(names[i]); free(names); return EL_STR("[]"); }
size_t pos = 0;
buf[pos++] = '[';
for (size_t i = 0; i < count; i++) {
if (i > 0) buf[pos++] = ',';
buf[pos++] = '"';
for (const char* p = names[i]; *p; p++) {
if (*p == '"' || *p == '\\') buf[pos++] = '\\';
else if (*p == '\n') { buf[pos++] = '\\'; buf[pos++] = 'n'; continue; }
else if (*p == '\t') { buf[pos++] = '\\'; buf[pos++] = 't'; continue; }
buf[pos++] = *p;
}
buf[pos++] = '"';
free(names[i]);
}
free(names);
buf[pos++] = ']';
buf[pos] = '\0';
return el_wrap_str(buf);
}
/* fs_exists — true iff stat(path) succeeds. Symlinks are followed. */
el_val_t fs_exists(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
if (!path || !*path) return 0;
struct stat st;
return (el_val_t)(stat(path, &st) == 0 ? 1 : 0);
}
/* fs_mkdir — create directory at path with mode 0755, mkdir -p semantics.
* Returns 1 if path exists or was created (incl. all parents); 0 on failure.
* Walks the path component-by-component so missing intermediate dirs are
* also created. An existing leaf is not an error. */
el_val_t fs_mkdir(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
if (!path || !*path) return 0;
size_t n = strlen(path);
char* buf = malloc(n + 1);
if (!buf) return 0;
memcpy(buf, path, n + 1);
/* Walk components; create each prefix in turn. */
for (size_t i = 1; i <= n; i++) {
if (buf[i] == '/' || buf[i] == '\0') {
char saved = buf[i];
buf[i] = '\0';
if (buf[0] != '\0') {
if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
/* Tolerate the case where this prefix exists as a non-dir
* only when stat says it's a directory. */
struct stat st;
if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
free(buf);
return 0;
}
}
}
buf[i] = saved;
}
}
free(buf);
return 1;
}
/* ── URL encoding ─────────────────────────────────────────────────────────── */
/* RFC 3986 percent-encoding for URL components (form bodies, query strings).
* Unreserved set: A-Z a-z 0-9 - _ . ~ — passed through verbatim.
* Everything else (including space) becomes %XX hex. */
el_val_t url_encode(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
static const char hex[] = "0123456789ABCDEF";
size_t n = strlen(s);
char* out = el_strbuf(n * 3);
size_t o = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if ((c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~') {
out[o++] = (char)c;
} else {
out[o++] = '%';
out[o++] = hex[(c >> 4) & 0xF];
out[o++] = hex[c & 0xF];
}
}
out[o] = '\0';
return el_wrap_str(out);
}
/* Decode percent-encoded URL component. '+' becomes space (form-encoded);
* malformed %-escapes are emitted verbatim. */
el_val_t url_decode(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
char* out = el_strbuf(n);
size_t o = 0;
for (size_t i = 0; i < n; i++) {
char c = s[i];
if (c == '+') {
out[o++] = ' ';
} else if (c == '%' && i + 2 < n) {
char h1 = s[i + 1], h2 = s[i + 2];
int v1 = (h1 >= '0' && h1 <= '9') ? h1 - '0'
: (h1 >= 'a' && h1 <= 'f') ? h1 - 'a' + 10
: (h1 >= 'A' && h1 <= 'F') ? h1 - 'A' + 10 : -1;
int v2 = (h2 >= '0' && h2 <= '9') ? h2 - '0'
: (h2 >= 'a' && h2 <= 'f') ? h2 - 'a' + 10
: (h2 >= 'A' && h2 <= 'F') ? h2 - 'A' + 10 : -1;
if (v1 >= 0 && v2 >= 0) {
out[o++] = (char)((v1 << 4) | v2);
i += 2;
} else {
out[o++] = c;
}
} else {
out[o++] = c;
}
}
out[o] = '\0';
return el_wrap_str(out);
}
/* ── html_raw ────────────────────────────────────────────────────────────────
* Identity passthrough for raw HTML template interpolation.
* El's {raw(expr)} compiles to html_raw(expr) — the value is output as-is
* without any escaping. The caller is responsible for safety.
*/
el_val_t html_raw(el_val_t s) {
return s;
}
/* ── html_escape ─────────────────────────────────────────────────────────────
* Escape < > " ' & for safe HTML text interpolation.
* El's {expr} in HTML templates compiles to html_escape(expr).
*/
el_val_t html_escape(el_val_t sv) {
const char* src = EL_CSTR(sv);
if (!src) return EL_STR("");
size_t len = strlen(src);
/* Worst case: every byte → 6 chars (&quot;) */
char* out = (char*)malloc(len * 6 + 1);
if (!out) return sv;
el_arena_track(out);
char* p = out;
for (size_t i = 0; i < len; i++) {
unsigned char c = (unsigned char)src[i];
switch (c) {
case '&': memcpy(p, "&amp;", 5); p += 5; break;
case '<': memcpy(p, "&lt;", 4); p += 4; break;
case '>': memcpy(p, "&gt;", 4); p += 4; break;
case '"': memcpy(p, "&quot;", 6); p += 6; break;
case '\'': memcpy(p, "&#39;", 5); p += 5; break;
default: *p++ = (char)c; break;
}
}
*p = '\0';
return el_wrap_str(out);
}
/* ── HTML allowlist sanitizer ────────────────────────────────────────────────
* el_html_sanitize(input, allowlist_json)
*
* Strict allowlist HTML cleaner. Replaces the older denylist patterns
* (str_replace cascades that wrapped dangerous tags in HTML comments and
* renamed `on*` attributes). The denylist approach is fragile: comment-
* wrapping can be re-broken by a literal `-->` inside an attacker-supplied
* attribute value, and every new attack vector requires a code change.
*
* Design:
* - Single-pass byte-level state machine.
* - Tag and attribute names are matched case-insensitively against the
* allowlist. Unknown tags are dropped entirely (the open and close
* markers are stripped; their inner text content survives, escaped).
* - A small set of "dangerous container" tags (script, style, iframe,
* object, embed, form, plus a few rarer ones) drop themselves AND
* their full subtree — text between `<script>` and `</script>` is
* CDATA-like and must not be re-emitted as escaped text either.
* - Comments (<!-- -->), doctype (<!DOCTYPE>), CDATA (<![CDATA[...]]>),
* and processing instructions (<?...?>) are dropped entirely.
* - Text content outside dropped subtrees is HTML-escaped (&, <, >, ", ').
* - Attribute values are unquoted/dequoted, then re-emitted with double
* quotes around the cleanly-escaped value.
* - For `<a href>` and any `src` attribute, the URL scheme is validated:
* only http:, https:, mailto:, fragment-only `#anchor`, or relative
* paths are allowed. Anything else (javascript:, data:, vbscript:,
* about:, file:, etc.) drops the attribute.
* - Self-closing void tags (br, hr, img, etc.) emit without a close tag.
* - Malformed input (unclosed tag at EOF, bad attribute syntax) drops
* the pending tag and continues. Pre-encoded entities (&lt;, &amp;,
* etc.) are passed through verbatim — the browser will decode them
* safely on render.
*
* Allowlist format (JSON string):
* {"p":[],"a":["href","title"],"strong":[],...}
* - Key = lowercase tag name.
* - Value = JSON array of allowed attribute names (lowercase).
* - Empty array means tag allowed but no attributes survive.
*
* Output is a freshly-allocated arena-tracked el_val_t string. */
/* Internal byte buffer with realloc-doubling. Used during sanitization;
* the final result is copied into an arena-tracked el_strbuf so the caller
* sees standard runtime memory semantics. */
typedef struct {
char* data;
size_t len;
size_t cap;
} html_buf_t;
static void html_buf_init(html_buf_t* b) {
b->cap = 256;
b->data = malloc(b->cap);
if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
b->len = 0;
}
static void html_buf_grow(html_buf_t* b, size_t need) {
if (b->len + need + 1 <= b->cap) return;
size_t nc = b->cap;
while (b->len + need + 1 > nc) nc *= 2;
char* nd = realloc(b->data, nc);
if (!nd) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
b->data = nd;
b->cap = nc;
}
static void html_buf_putc(html_buf_t* b, char c) {
html_buf_grow(b, 1);
b->data[b->len++] = c;
}
static void html_buf_puts(html_buf_t* b, const char* s) {
if (!s) return;
size_t n = strlen(s);
html_buf_grow(b, n);
memcpy(b->data + b->len, s, n);
b->len += n;
}
static void html_buf_free(html_buf_t* b) {
free(b->data);
b->data = NULL;
b->len = b->cap = 0;
}
/* ASCII tolower, locale-independent. */
static int html_tolower(int c) {
return (c >= 'A' && c <= 'Z') ? c + 32 : c;
}
/* Case-insensitive ASCII compare of [a, a+n) against c-string `s`.
* Returns 1 iff lengths match and bytes are equal under tolower. */
static int html_ieq_n(const char* a, size_t n, const char* s) {
if (!a || !s) return 0;
if (strlen(s) != n) return 0;
for (size_t i = 0; i < n; i++) {
if (html_tolower((unsigned char)a[i]) != html_tolower((unsigned char)s[i])) return 0;
}
return 1;
}
/* Case-insensitive ASCII compare of two byte slices. */
static int html_iemem(const char* a, const char* b, size_t n) {
for (size_t i = 0; i < n; i++) {
if (html_tolower((unsigned char)a[i]) != html_tolower((unsigned char)b[i])) return 0;
}
return 1;
}
/* Walk a JSON allowlist object and find the value (an array) for a given
* tag key, comparing case-insensitively. On hit returns a pointer to the
* opening `[` of the array and writes the byte length of the array span
* (including the brackets) to *out_len. On miss returns NULL.
*
* The parser is intentionally tiny: it does not handle escapes inside
* keys (allowlist authors do not need them), and it relies on balanced
* brackets/quotes within the value array. */
static const char* html_allowlist_find(const char* allow, const char* tag,
size_t tag_len, size_t* out_len) {
if (!allow) return NULL;
const char* p = allow;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '{') return NULL;
p++;
while (*p) {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p == '}' || *p == 0) return NULL;
if (*p != '"') return NULL;
p++;
const char* k = p;
while (*p && *p != '"') p++;
if (*p != '"') return NULL;
size_t klen = (size_t)(p - k);
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != ':') return NULL;
p++;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') return NULL;
const char* arr_start = p;
int depth = 0;
int in_str = 0;
while (*p) {
char c = *p;
if (in_str) {
if (c == '\\' && p[1]) { p += 2; continue; }
if (c == '"') in_str = 0;
} else {
if (c == '"') in_str = 1;
else if (c == '[') depth++;
else if (c == ']') { depth--; if (depth == 0) { p++; break; } }
}
p++;
}
size_t alen = (size_t)(p - arr_start);
int match = (klen == tag_len) && html_iemem(k, tag, klen);
if (match) {
if (out_len) *out_len = alen;
return arr_start;
}
}
return NULL;
}
/* Returns 1 iff `attr` (length attr_len) appears as a string element
* in the JSON array slice [arr, arr+arr_len). Comparison is case-
* insensitive. */
static int html_attr_in_array(const char* arr, size_t arr_len,
const char* attr, size_t attr_len) {
if (!arr || arr_len < 2) return 0;
const char* p = arr + 1;
const char* end = arr + arr_len - 1;
while (p < end) {
while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
if (p >= end) return 0;
if (*p != '"') return 0;
p++;
const char* s = p;
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) p++;
p++;
}
if (p >= end) return 0;
size_t slen = (size_t)(p - s);
p++;
if (slen == attr_len && html_iemem(s, attr, slen)) return 1;
}
return 0;
}
/* Hard-coded set of tags whose content is ALSO dropped (entire subtree). */
static int html_is_dangerous_container(const char* tag, size_t tag_len) {
static const char* names[] = {
"script", "style", "iframe", "object", "embed", "form",
"noscript", "noembed", "template", "svg", "math", "frame",
"frameset", "applet", "audio", "video", "source", "track",
NULL
};
for (int i = 0; names[i]; i++) {
if (html_ieq_n(tag, tag_len, names[i])) return 1;
}
return 0;
}
/* HTML void elements — emit without a close tag. */
static int html_is_void(const char* tag, size_t tag_len) {
static const char* names[] = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
NULL
};
for (int i = 0; names[i]; i++) {
if (html_ieq_n(tag, tag_len, names[i])) return 1;
}
return 0;
}
/* Append a single byte HTML-escaped into the output buffer. */
static void html_escape_byte(html_buf_t* out, unsigned char c) {
switch (c) {
case '<': html_buf_puts(out, "&lt;"); break;
case '>': html_buf_puts(out, "&gt;"); break;
case '"': html_buf_puts(out, "&quot;"); break;
case '\'': html_buf_puts(out, "&#39;"); break;
default: html_buf_putc(out, (char)c); break;
}
}
/* Validate a URL value against the allowlist of safe schemes for hrefs.
* Returns 1 iff the URL is safe to emit. Acceptable forms:
* - http:// or https:// (case-insensitive)
* - mailto:
* - fragment-only `#anchor`
* - relative path that does not contain a colon before the first
* slash/?/# (so `foo/bar`, `/foo`, `?x=1` are OK; `javascript:x` is
* not — its colon precedes any path/hash/query separator).
*
* URL leading whitespace and embedded ASCII control bytes (TAB, LF, CR)
* are stripped before the scheme test, mirroring how browsers normalise
* URLs (these bytes are otherwise a known XSS bypass: `java\tscript:`). */
static int html_url_is_safe(const char* url, size_t len) {
if (!url || len == 0) return 1; /* empty href is harmless */
size_t i = 0;
while (i < len) {
unsigned char c = (unsigned char)url[i];
if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0x0B || c == 0x0C) {
i++; continue;
}
break;
}
if (i >= len) return 1; /* whitespace only */
if (url[i] == '#') return 1; /* fragment only */
if (url[i] == '/' || url[i] == '?') return 1; /* relative */
/* Find the first scheme-terminating character. */
size_t scheme_end = (size_t)-1;
for (size_t j = i; j < len; j++) {
char c = url[j];
if (c == ':') { scheme_end = j; break; }
if (c == '/' || c == '?' || c == '#') break;
}
if (scheme_end == (size_t)-1) return 1; /* no colon → relative path */
/* Lowercase the scheme, stripping embedded control bytes. */
char scheme[32];
size_t sl = 0;
for (size_t j = i; j < scheme_end && sl < sizeof(scheme) - 1; j++) {
unsigned char c = (unsigned char)url[j];
if (c == '\t' || c == '\n' || c == '\r' || c == 0x0B || c == 0x0C) continue;
scheme[sl++] = (char)html_tolower(c);
}
scheme[sl] = '\0';
if (strcmp(scheme, "http") == 0) return 1;
if (strcmp(scheme, "https") == 0) return 1;
if (strcmp(scheme, "mailto") == 0) return 1;
return 0;
}
el_val_t el_html_sanitize(el_val_t input_v, el_val_t allowlist_v) {
const char* input = EL_CSTR(input_v);
const char* allow = EL_CSTR(allowlist_v);
if (!input) return el_wrap_str(el_strdup(""));
if (!allow) allow = "{}";
size_t in_len = strlen(input);
html_buf_t out;
html_buf_init(&out);
size_t i = 0;
while (i < in_len) {
unsigned char c = (unsigned char)input[i];
if (c != '<') {
/* Plain text — escape and emit. We pass `&` through verbatim
* to preserve pre-encoded entities (`&lt;`, `&amp;`, `&#x...;`)
* which the browser will decode safely. */
if (c == '&') html_buf_putc(&out, '&');
else html_escape_byte(&out, c);
i++;
continue;
}
/* `<` — try to parse a tag. */
if (i + 1 >= in_len) {
html_buf_puts(&out, "&lt;");
i++;
continue;
}
/* Comments, doctype, CDATA, processing instructions — drop entirely. */
if (input[i + 1] == '!') {
if (i + 3 < in_len && input[i + 2] == '-' && input[i + 3] == '-') {
size_t j = i + 4;
while (j + 2 < in_len && !(input[j] == '-' && input[j + 1] == '-' && input[j + 2] == '>')) j++;
if (j + 2 < in_len) i = j + 3;
else i = in_len;
continue;
}
size_t j = i + 2;
while (j < in_len && input[j] != '>') j++;
i = (j < in_len) ? j + 1 : in_len;
continue;
}
if (input[i + 1] == '?') {
size_t j = i + 2;
while (j < in_len && input[j] != '>') j++;
i = (j < in_len) ? j + 1 : in_len;
continue;
}
int is_close = 0;
size_t name_start = i + 1;
if (input[i + 1] == '/') {
is_close = 1;
name_start = i + 2;
}
if (name_start >= in_len) {
html_buf_puts(&out, "&lt;");
i++;
continue;
}
unsigned char nc = (unsigned char)input[name_start];
if (!((nc >= 'a' && nc <= 'z') || (nc >= 'A' && nc <= 'Z'))) {
/* `<` followed by non-letter — emit as escaped text. */
html_buf_puts(&out, "&lt;");
i++;
continue;
}
size_t name_end = name_start;
while (name_end < in_len) {
unsigned char x = (unsigned char)input[name_end];
if ((x >= 'a' && x <= 'z') || (x >= 'A' && x <= 'Z') ||
(x >= '0' && x <= '9') || x == '-' || x == '_' || x == ':') {
name_end++;
} else {
break;
}
}
const char* tag = input + name_start;
size_t tag_len = name_end - name_start;
/* Find the `>` that closes this tag, respecting quoted attrs. */
size_t cur = name_end;
int self_close = 0;
while (cur < in_len) {
unsigned char x = (unsigned char)input[cur];
if (x == '"' || x == '\'') {
unsigned char q = x;
cur++;
while (cur < in_len && (unsigned char)input[cur] != q) cur++;
if (cur < in_len) cur++; /* skip closing quote */
continue;
}
if (x == '/' && cur + 1 < in_len && input[cur + 1] == '>') {
self_close = 1;
break;
}
if (x == '>') break;
cur++;
}
if (cur >= in_len) {
/* Malformed: unclosed tag at EOF. Drop the rest of the input. */
i = in_len;
continue;
}
size_t tag_end = self_close ? cur + 2 : cur + 1; /* one past `>` */
/* Dangerous container — drop the whole subtree. */
if (!is_close && html_is_dangerous_container(tag, tag_len)) {
if (self_close || html_is_void(tag, tag_len)) {
i = tag_end;
continue;
}
size_t scan = tag_end;
int found_close = 0;
while (scan < in_len) {
if (input[scan] != '<') { scan++; continue; }
if (scan + 1 < in_len && input[scan + 1] == '/') {
size_t cn_start = scan + 2;
size_t cn_end = cn_start;
while (cn_end < in_len) {
unsigned char x = (unsigned char)input[cn_end];
if ((x >= 'a' && x <= 'z') || (x >= 'A' && x <= 'Z') ||
(x >= '0' && x <= '9') || x == '-' || x == '_' || x == ':') {
cn_end++;
} else break;
}
if (cn_end - cn_start == tag_len &&
html_iemem(input + cn_start, tag, tag_len)) {
size_t end_close = cn_end;
while (end_close < in_len && input[end_close] != '>') end_close++;
i = (end_close < in_len) ? end_close + 1 : in_len;
found_close = 1;
break;
}
}
scan++;
}
if (!found_close) {
/* No matching close — drop everything from here on. */
i = in_len;
}
continue;
}
/* Look up the tag in the allowlist. */
size_t arr_len = 0;
const char* arr = html_allowlist_find(allow, tag, tag_len, &arr_len);
if (!arr) {
/* Tag not allowed. Drop the open/close marker; inner text is
* processed by the outer loop and re-emitted as escaped text. */
i = tag_end;
continue;
}
if (is_close) {
if (!html_is_void(tag, tag_len)) {
html_buf_putc(&out, '<');
html_buf_putc(&out, '/');
for (size_t k = 0; k < tag_len; k++) {
html_buf_putc(&out, (char)html_tolower((unsigned char)tag[k]));
}
html_buf_putc(&out, '>');
}
i = tag_end;
continue;
}
/* Allowed open tag. Emit `<name` and walk the attributes between
* `name_end` and the closing `>`. */
html_buf_putc(&out, '<');
for (size_t k = 0; k < tag_len; k++) {
html_buf_putc(&out, (char)html_tolower((unsigned char)tag[k]));
}
size_t a = name_end;
while (a < cur) {
unsigned char x = (unsigned char)input[a];
if (x == ' ' || x == '\t' || x == '\n' || x == '\r' || x == '/') { a++; continue; }
size_t an_start = a;
while (a < cur) {
unsigned char y = (unsigned char)input[a];
if (y == '=' || y == ' ' || y == '\t' || y == '\n' || y == '\r' || y == '/' || y == '>') break;
a++;
}
size_t an_len = a - an_start;
if (an_len == 0) { a++; continue; }
size_t av_start = 0;
size_t av_len = 0;
int has_value = 0;
size_t b = a;
while (b < cur && (input[b] == ' ' || input[b] == '\t' || input[b] == '\n' || input[b] == '\r')) b++;
if (b < cur && input[b] == '=') {
has_value = 1;
b++;
while (b < cur && (input[b] == ' ' || input[b] == '\t' || input[b] == '\n' || input[b] == '\r')) b++;
if (b < cur && (input[b] == '"' || input[b] == '\'')) {
unsigned char q = (unsigned char)input[b];
b++;
av_start = b;
while (b < cur && (unsigned char)input[b] != q) b++;
av_len = b - av_start;
if (b < cur) b++;
} else {
av_start = b;
while (b < cur) {
unsigned char y = (unsigned char)input[b];
if (y == ' ' || y == '\t' || y == '\n' || y == '\r' || y == '>') break;
b++;
}
av_len = b - av_start;
}
a = b;
}
if (!html_attr_in_array(arr, arr_len, input + an_start, an_len)) continue;
int is_href = (an_len == 4 && html_iemem(input + an_start, "href", 4));
int is_src = (an_len == 3 && html_iemem(input + an_start, "src", 3));
if ((is_href || is_src) && has_value) {
if (!html_url_is_safe(input + av_start, av_len)) continue;
}
html_buf_putc(&out, ' ');
for (size_t k = 0; k < an_len; k++) {
html_buf_putc(&out, (char)html_tolower((unsigned char)input[an_start + k]));
}
if (has_value) {
html_buf_puts(&out, "=\"");
for (size_t k = 0; k < av_len; k++) {
unsigned char y = (unsigned char)input[av_start + k];
/* Re-escape so the emitted attribute is well-formed
* double-quoted HTML. `&` passes through to preserve
* pre-encoded entities. */
if (y == '"') html_buf_puts(&out, "&quot;");
else if (y == '<') html_buf_puts(&out, "&lt;");
else if (y == '>') html_buf_puts(&out, "&gt;");
else html_buf_putc(&out, (char)y);
}
html_buf_putc(&out, '"');
}
}
html_buf_putc(&out, '>');
i = tag_end;
}
/* Copy into arena-tracked buffer so the standard runtime memory model
* applies to the returned string. */
char* result = el_strbuf(out.len);
memcpy(result, out.data, out.len);
result[out.len] = '\0';
html_buf_free(&out);
return el_wrap_str(result);
}
/* ── JSON ────────────────────────────────────────────────────────────────── */
/* True iff the segment is non-empty and every byte is an ASCII digit. We treat
* such segments as numeric array indices when walking a dot-path; mixed names
* like "0a" remain object-key lookups, so a key named "0" still wins over an
* index when the surrounding container is an object. */
static int json_path_seg_is_index(const char* seg, size_t n) {
if (n == 0) return 0;
for (size_t i = 0; i < n; i++) {
if (seg[i] < '0' || seg[i] > '9') return 0;
}
return 1;
}
/* Skip JSON whitespace. */
static const char* json_skip_ws(const char* p) {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
return p;
}
/* Descend one segment into the JSON cursor `p`.
* - If `p` points at an array `[...]` and the segment is all digits,
* advance to that element (zero-based).
* - Otherwise treat the segment as an object key and use json_find_key
* scoped to a one-level slice of the current container.
* Returns NULL if the descent fails (segment not found, container mismatch).
*
* `seg` is a pointer into the original path string and `seg_len` is its
* byte length — this avoids an extra alloc per segment. */
static const char* json_path_descend(const char* p, const char* seg, size_t seg_len) {
if (!p || !seg) return NULL;
p = json_skip_ws(p);
if (*p == '[' && json_path_seg_is_index(seg, seg_len)) {
long idx = 0;
for (size_t i = 0; i < seg_len; i++) idx = idx * 10 + (seg[i] - '0');
p++; /* step past '[' */
p = json_skip_ws(p);
long cur = 0;
while (*p && *p != ']') {
if (cur == idx) return p;
const char* end = json_skip_value(p);
if (!end || end == p) return NULL;
p = json_skip_ws(end);
if (*p == ',') { p++; p = json_skip_ws(p); cur++; continue; }
/* No comma after this element — only acceptable at the closing ']',
* which means we ran out of elements. */
break;
}
return NULL;
}
/* Object lookup. json_find_key walks at depth 1 of whatever container it
* receives, so we slice from `p` onwards. Caller already positioned us at
* the opening '{' (or at whitespace before it). */
if (*p != '{') return NULL;
/* Build a NUL-terminated copy of the key segment for the lookup. We only
* pay this cost when the segment isn't a numeric index. */
char stack_key[256];
char* k = stack_key;
if (seg_len + 1 > sizeof(stack_key)) {
k = malloc(seg_len + 1);
if (!k) return NULL;
}
memcpy(k, seg, seg_len);
k[seg_len] = '\0';
const char* found = json_find_key(p, k);
if (k != stack_key) free(k);
return found;
}
/* Read the JSON value at `p` into a freshly-allocated, arena-owned el_val_t.
* - String -> unescaped, wrapped el_val_t string
* - Anything else -> raw JSON slice as a string (matches the historical
* json_get behaviour: numbers/bools/null come back stringified). */
static el_val_t json_read_value(const char* p) {
p = json_skip_ws(p);
if (*p == '"') {
p++;
size_t cap = strlen(p) + 1;
char* out = el_strbuf(cap);
char* w = out;
while (*p && *p != '"') {
if (*p == '\\' && *(p+1)) {
p++;
switch (*p) {
case '"': *w++ = '"'; break;
case '\\': *w++ = '\\'; break;
case '/': *w++ = '/'; break;
case 'n': *w++ = '\n'; break;
case 'r': *w++ = '\r'; break;
case 't': *w++ = '\t'; break;
default: *w++ = *p; break;
}
} else {
*w++ = *p;
}
p++;
}
*w = '\0';
return el_wrap_str(out);
}
/* Object/array/number/bool/null — return the raw slice up to the value's
* end. json_skip_value tracks brace/bracket/string state so nested objects
* round-trip cleanly. */
const char* end = json_skip_value(p);
if (!end) end = p;
size_t n = (size_t)(end - p);
/* Strip trailing whitespace from scalar values so callers don't see
* `123 ` when they parsed a pretty-printed number. */
while (n > 0 && (p[n-1] == ' ' || p[n-1] == '\t' || p[n-1] == '\n' || p[n-1] == '\r')) {
n--;
}
char* out = el_strbuf(n);
memcpy(out, p, n);
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t json_get(el_val_t jsonv, el_val_t keyv) {
const char* json = EL_CSTR(jsonv);
const char* key = EL_CSTR(keyv);
if (!json || !key) return el_wrap_str(el_strdup(""));
/* Fast path: key contains no '.' — keep the historical single-segment
* substring search so existing callers retain their O(strlen) cost
* profile. The dot-path walker is only paid for when needed. */
if (!strchr(key, '.')) {
size_t klen = strlen(key);
char stack_pat[512];
char* pattern;
if (klen + 5 <= sizeof(stack_pat)) {
pattern = stack_pat;
} else {
pattern = malloc(klen + 5);
if (!pattern) return el_wrap_str(el_strdup(""));
}
snprintf(pattern, klen + 5, "\"%s\":", key);
const char* p = strstr(json, pattern);
if (pattern != stack_pat) free(pattern);
if (!p) return el_wrap_str(el_strdup(""));
p += strlen(key) + 3; /* skip "key": */
return json_read_value(p);
}
/* Dot-path traversal. Walk segments left to right; at each step, descend
* into the current container by either array index (all-digit segment on
* an array cursor) or object key. */
const char* cursor = json_skip_ws(json);
const char* seg_start = key;
const char* k = key;
while (1) {
if (*k == '.' || *k == '\0') {
size_t seg_len = (size_t)(k - seg_start);
cursor = json_path_descend(cursor, seg_start, seg_len);
if (!cursor) return el_wrap_str(el_strdup(""));
if (*k == '\0') break;
k++;
seg_start = k;
continue;
}
k++;
}
return json_read_value(cursor);
}
/* ── Float bit-cast helpers ──────────────────────────────────────────────── */
/* `el_to_float` and `el_from_float` are exposed in el_runtime.h as static
* inlines so generated programs (which #include the header) can call them
* for Float literals. No definitions are needed here. */
/* ── JSON parser (recursive descent) ─────────────────────────────────────── */
/*
* Parsed JSON representation:
* - object -> ElMap (keys & values are el_val_t)
* - array -> ElList
* - string -> EL_STR-wrapped char* (allocated)
* - number -> int (el_val_t) if integer, otherwise el_from_float(double)
* - true -> 1
* - false -> 0
* - null -> EL_NULL (0)
*
* Note: there is no runtime type tag — parsed numbers cannot be
* distinguished from booleans by the runtime alone. The codegen tracks
* types separately. This matches the rest of el_val_t's type-erased model.
*/
/* JsonParser struct is forward-declared near the HTTP/Engram section. */
static void jp_skip_ws(JsonParser* jp) {
while (jp->p < jp->end) {
char c = *jp->p;
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') jp->p++;
else break;
}
}
static el_val_t jp_parse_value(JsonParser* jp);
/* Parse a JSON string literal (the opening " has NOT yet been consumed). */
static char* jp_parse_string_raw(JsonParser* jp) {
if (jp->p >= jp->end || *jp->p != '"') { jp->err = 1; return el_strdup(""); }
jp->p++;
size_t cap = 32, len = 0;
char* out = malloc(cap);
if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
while (jp->p < jp->end && *jp->p != '"') {
char c = *jp->p++;
if (c == '\\' && jp->p < jp->end) {
char esc = *jp->p++;
switch (esc) {
case '"': c = '"'; break;
case '\\': c = '\\'; break;
case '/': c = '/'; break;
case 'b': c = '\b'; break;
case 'f': c = '\f'; break;
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'u': {
/* Skip 4 hex digits; emit '?' as a placeholder */
for (int i = 0; i < 4 && jp->p < jp->end; i++) jp->p++;
c = '?';
break;
}
default: c = esc; break;
}
}
if (len + 1 >= cap) {
cap *= 2;
out = realloc(out, cap);
if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
}
out[len++] = c;
}
if (jp->p < jp->end && *jp->p == '"') jp->p++;
else jp->err = 1;
out[len] = '\0';
return out;
}
static el_val_t jp_parse_number(JsonParser* jp) {
const char* start = jp->p;
int is_float = 0;
if (jp->p < jp->end && (*jp->p == '-' || *jp->p == '+')) jp->p++;
while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
if (jp->p < jp->end && *jp->p == '.') {
is_float = 1; jp->p++;
while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
}
if (jp->p < jp->end && (*jp->p == 'e' || *jp->p == 'E')) {
is_float = 1; jp->p++;
if (jp->p < jp->end && (*jp->p == '+' || *jp->p == '-')) jp->p++;
while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
}
size_t n = (size_t)(jp->p - start);
char buf[64];
if (n >= sizeof(buf)) n = sizeof(buf) - 1;
memcpy(buf, start, n);
buf[n] = '\0';
if (is_float) return el_from_float(strtod(buf, NULL));
return (el_val_t)strtoll(buf, NULL, 10);
}
static el_val_t jp_parse_array(JsonParser* jp) {
if (jp->p < jp->end && *jp->p == '[') jp->p++;
el_val_t lst = el_list_empty();
jp_skip_ws(jp);
if (jp->p < jp->end && *jp->p == ']') { jp->p++; return lst; }
while (jp->p < jp->end) {
jp_skip_ws(jp);
el_val_t v = jp_parse_value(jp);
lst = el_list_append(lst, v);
jp_skip_ws(jp);
if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
if (jp->p < jp->end && *jp->p == ']') { jp->p++; break; }
jp->err = 1;
break;
}
return lst;
}
static el_val_t jp_parse_object(JsonParser* jp) {
if (jp->p < jp->end && *jp->p == '{') jp->p++;
el_val_t m = el_map_new(0);
jp_skip_ws(jp);
if (jp->p < jp->end && *jp->p == '}') { jp->p++; return m; }
while (jp->p < jp->end) {
jp_skip_ws(jp);
char* key = jp_parse_string_raw(jp);
jp_skip_ws(jp);
if (jp->p < jp->end && *jp->p == ':') jp->p++;
else { jp->err = 1; free(key); break; }
jp_skip_ws(jp);
el_val_t v = jp_parse_value(jp);
m = el_map_set(m, EL_STR(key), v);
jp_skip_ws(jp);
if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
if (jp->p < jp->end && *jp->p == '}') { jp->p++; break; }
jp->err = 1;
break;
}
return m;
}
static el_val_t jp_parse_value(JsonParser* jp) {
jp_skip_ws(jp);
if (jp->p >= jp->end) { jp->err = 1; return EL_NULL; }
char c = *jp->p;
if (c == '"') return el_wrap_str(jp_parse_string_raw(jp));
if (c == '{') return jp_parse_object(jp);
if (c == '[') return jp_parse_array(jp);
if (c == '-' || isdigit((unsigned char)c)) return jp_parse_number(jp);
if (c == 't' && jp->p + 4 <= jp->end && strncmp(jp->p, "true", 4) == 0) { jp->p += 4; return 1; }
if (c == 'f' && jp->p + 5 <= jp->end && strncmp(jp->p, "false", 5) == 0) { jp->p += 5; return 0; }
if (c == 'n' && jp->p + 4 <= jp->end && strncmp(jp->p, "null", 4) == 0) { jp->p += 4; return EL_NULL; }
jp->err = 1;
return EL_NULL;
}
el_val_t json_parse(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return EL_NULL;
JsonParser jp = { .p = s, .end = s + strlen(s), .err = 0 };
el_val_t v = jp_parse_value(&jp);
if (jp.err) return EL_NULL;
return v;
}
/* ── JSON stringify ──────────────────────────────────────────────────────── */
/*
* Stringify policy: el_val_t is type-erased, so we cannot perfectly
* round-trip arbitrary values. We use these heuristics:
* - If value is an ElList pointer (in the heap range), serialize as array.
* - If value is an ElMap pointer, serialize as object.
* - If value looks like a printable string pointer, serialize as string.
* - Otherwise serialize as integer.
* This is best-effort. Programs that need exact control should build the
* string directly. A pointer test is the cheapest way to disambiguate
* from small integers without a separate type tag.
*/
/* JsonBuf struct is forward-declared near the HTTP section so HTTP helpers
* can use it. Its definition appears there. */
static void jb_init(JsonBuf* b) {
b->cap = 64; b->len = 0;
b->buf = malloc(b->cap);
if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
b->buf[0] = '\0';
}
static void jb_reserve(JsonBuf* b, size_t add) {
if (b->len + add + 1 > b->cap) {
while (b->len + add + 1 > b->cap) b->cap *= 2;
b->buf = realloc(b->buf, b->cap);
if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
}
}
static void jb_putc(JsonBuf* b, char c) {
jb_reserve(b, 1);
b->buf[b->len++] = c;
b->buf[b->len] = '\0';
}
static void jb_puts(JsonBuf* b, const char* s) {
size_t n = strlen(s);
jb_reserve(b, n);
memcpy(b->buf + b->len, s, n);
b->len += n;
b->buf[b->len] = '\0';
}
static void jb_emit_escaped(JsonBuf* b, const char* s) {
jb_putc(b, '"');
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
switch (c) {
case '"': jb_puts(b, "\\\""); break;
case '\\': jb_puts(b, "\\\\"); break;
case '\b': jb_puts(b, "\\b"); break;
case '\f': jb_puts(b, "\\f"); break;
case '\n': jb_puts(b, "\\n"); break;
case '\r': jb_puts(b, "\\r"); break;
case '\t': jb_puts(b, "\\t"); break;
default:
if (c < 0x20) {
char tmp[8];
snprintf(tmp, sizeof(tmp), "\\u%04x", c);
jb_puts(b, tmp);
} else {
jb_putc(b, (char)c);
}
break;
}
}
jb_putc(b, '"');
}
/* Heuristic: is this el_val_t likely a pointer to an ElList?
* We can't fully verify, but pointers are large addresses, integers small.
* Treat values whose magnitude exceeds 2^32 as potential pointers and
* sniff by reading the header conservatively.
*
* Simpler heuristic: if the value reads as a printable string, treat as
* string; otherwise as integer. Lists/Maps are encoded as struct pointers,
* which have leading binary bytes — so they won't look like strings. */
static int looks_like_string(el_val_t v) {
if (v == 0) return 0;
/* Treat plausible heap addresses as candidates.
* Threshold: 4 GiB (0x100000000). On 64-bit systems heap addresses from
* malloc/mmap start well above 4 GiB (ASLR pushes them to ~0x7f...).
* El integer values (counters, unix timestamps up to ~2106) all fit below
* 0x100000000 (4294967296). The old threshold of 1,000,000 caused unix
* timestamps (~1.7e9) to be misidentified as string pointers — a segfault
* risk in json_stringify and jb_emit_value. */
uintptr_t p = (uintptr_t)v;
if (p < 0x100000000ULL) return 0; /* integers, timestamps, counters */
if (p < 0x1000) return 0;
/* Sniff first bytes for printable */
const unsigned char* s = (const unsigned char*)p;
for (int i = 0; i < 16; i++) {
unsigned char c = s[i];
if (c == '\0') return 1; /* terminated string (empty string is still a valid string) */
/* Reject C0 control chars (non-whitespace), allow UTF-8 high bytes.
* 0x09-0x0d = tab/newline/cr/vt/ff (whitespace, OK)
* 0x20-0x7e = printable ASCII (OK)
* 0x7f = DEL (reject)
* 0x80-0xff = UTF-8 continuation/lead bytes (OK for multi-byte chars) */
if (c < 0x09 || (c > 0x0d && c < 0x20) || c == 0x7f) return 0;
}
return 1; /* 16+ printable bytes — call it a string */
}
static void jb_emit_value(JsonBuf* b, el_val_t v);
static void jb_emit_int(JsonBuf* b, int64_t n) {
char tmp[32];
snprintf(tmp, sizeof(tmp), "%lld", (long long)n);
jb_puts(b, tmp);
}
static void jb_emit_value(JsonBuf* b, el_val_t v) {
if (v == EL_NULL) { jb_puts(b, "null"); return; }
if (looks_like_string(v)) {
jb_emit_escaped(b, EL_CSTR(v));
return;
}
jb_emit_int(b, (int64_t)v);
}
el_val_t json_stringify(el_val_t v) {
JsonBuf b; jb_init(&b);
jb_emit_value(&b, v);
return el_wrap_str(b.buf);
}
/* ── JSON substring accessors ────────────────────────────────────────────── */
/*
* These walk the raw JSON string looking for "key": at the top level (depth 1)
* of an object. They handle escaped quotes, nested objects/arrays, and
* whitespace around the colon.
*/
/* Find "key": at object-depth == 1 inside the JSON object string `s`.
* Returns pointer to the first byte of the value, or NULL. */
static const char* json_find_key(const char* s, const char* key) {
if (!s || !key) return NULL;
size_t klen = strlen(key);
int depth = 0;
int in_str = 0;
int escape = 0;
const char* p = s;
while (*p) {
char c = *p;
if (in_str) {
if (escape) { escape = 0; }
else if (c == '\\') { escape = 1; }
else if (c == '"') {
/* End of string. If we're at depth 1, check if this was a key. */
p++;
if (depth == 1) {
/* The string just ended at p-1. Check if it matches key
* and is followed by a colon. We need to backtrack to find
* the start of this string and compare. */
}
in_str = 0;
continue;
}
p++;
continue;
}
if (c == '"') {
/* Start of a string literal */
const char* str_start = p + 1;
const char* q = str_start;
int e = 0;
while (*q) {
if (e) { e = 0; q++; continue; }
if (*q == '\\') { e = 1; q++; continue; }
if (*q == '"') break;
q++;
}
size_t slen = (size_t)(q - str_start);
const char* after = (*q == '"') ? q + 1 : q;
/* If at depth 1 and matches key and followed by ':' -> got it */
if (depth == 1 && slen == klen && strncmp(str_start, key, klen) == 0) {
const char* r = after;
while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
if (*r == ':') {
r++;
while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
return r;
}
}
p = after;
continue;
}
if (c == '{' || c == '[') depth++;
else if (c == '}' || c == ']') depth--;
p++;
}
return NULL;
}
/* Skip a JSON value starting at p; return pointer past the value end. */
static const char* json_skip_value(const char* p) {
if (!p || !*p) return p;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p == '"') {
p++;
int e = 0;
while (*p) {
if (e) { e = 0; p++; continue; }
if (*p == '\\') { e = 1; p++; continue; }
if (*p == '"') { p++; break; }
p++;
}
return p;
}
if (*p == '{' || *p == '[') {
char open = *p;
char close = (open == '{') ? '}' : ']';
int depth = 0;
int in_str = 0;
int e = 0;
while (*p) {
char c = *p;
if (in_str) {
if (e) { e = 0; }
else if (c == '\\') { e = 1; }
else if (c == '"') in_str = 0;
p++;
continue;
}
if (c == '"') { in_str = 1; p++; continue; }
if (c == open) depth++;
else if (c == close) { depth--; p++; if (depth == 0) return p; continue; }
p++;
}
return p;
}
/* scalar: number, true/false/null */
while (*p && *p != ',' && *p != '}' && *p != ']' &&
*p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++;
return p;
}
el_val_t json_get_string(el_val_t json_str, el_val_t key) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
if (!p || *p != '"') return el_wrap_str(el_strdup(""));
p++;
JsonParser jp = { .p = p - 1, .end = json + (json ? strlen(json) : 0), .err = 0 };
char* parsed = jp_parse_string_raw(&jp);
if (jp.err) { free(parsed); return el_wrap_str(el_strdup("")); }
return el_wrap_str(parsed);
}
el_val_t json_get_int(el_val_t json_str, el_val_t key) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
if (!p) return 0;
if (*p == '"' || *p == '{' || *p == '[') return 0;
return (el_val_t)strtoll(p, NULL, 10);
}
el_val_t json_get_float(el_val_t json_str, el_val_t key) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
if (!p) return 0;
if (*p == '"' || *p == '{' || *p == '[') return 0;
return el_from_float(strtod(p, NULL));
}
el_val_t json_get_bool(el_val_t json_str, el_val_t key) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
if (!p) return 0;
if (strncmp(p, "true", 4) == 0) return 1;
return 0;
}
el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
/* Clear fs_read binary-length hint — result is a fresh null-terminated
* string, not the raw file bytes, so Content-Length must use strlen. */
_tl_fs_read_len = 0;
if (!p) return el_wrap_str(el_strdup(""));
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
char* out = el_strbuf(n);
memcpy(out, p, n);
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value) {
const char* json = EL_CSTR(json_str);
const char* k = EL_CSTR(key);
/* raw_val is the JSON value as-is (already encoded by the caller).
* If it looks like a plain (non-JSON) string, wrap it as a JSON string.
* Convention: callers pass pre-encoded values like "\"bob\"" for strings,
* "42" for numbers, "true"/"false" for booleans. */
const char* raw_val = EL_CSTR(value);
if (!k) k = "";
if (!raw_val) raw_val = "null";
if (!json || !*json) {
/* Build a fresh object */
JsonBuf b; jb_init(&b);
jb_putc(&b, '{');
jb_emit_escaped(&b, k);
jb_putc(&b, ':');
jb_puts(&b, raw_val);
jb_putc(&b, '}');
return el_wrap_str(b.buf);
}
const char* existing = json_find_key(json, k);
JsonBuf b; jb_init(&b);
if (existing) {
const char* end = json_skip_value(existing);
/* Copy [json .. existing) */
size_t prefix = (size_t)(existing - json);
jb_reserve(&b, prefix);
memcpy(b.buf + b.len, json, prefix);
b.len += prefix;
b.buf[b.len] = '\0';
jb_puts(&b, raw_val);
jb_puts(&b, end);
return el_wrap_str(b.buf);
}
/* Insert before closing '}'. Find last '}' */
size_t jl = strlen(json);
if (jl == 0) { free(b.buf); return el_wrap_str(el_strdup("{}")); }
/* Find last '}' from the end */
ssize_t close_idx = -1;
for (ssize_t i = (ssize_t)jl - 1; i >= 0; i--) {
if (json[i] == '}') { close_idx = i; break; }
}
if (close_idx < 0) {
free(b.buf);
return el_wrap_str(el_strdup(json));
}
/* Determine if object is empty: scan between last '{' and '}' for non-ws */
int empty = 1;
for (ssize_t i = close_idx - 1; i >= 0; i--) {
char c = json[i];
if (c == '{') break;
if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { empty = 0; break; }
}
/* Copy json[0..close_idx) */
jb_reserve(&b, (size_t)close_idx);
memcpy(b.buf + b.len, json, (size_t)close_idx);
b.len += (size_t)close_idx;
b.buf[b.len] = '\0';
if (!empty) jb_putc(&b, ',');
jb_emit_escaped(&b, k);
jb_putc(&b, ':');
jb_puts(&b, raw_val);
/* Append from close_idx onward */
jb_puts(&b, json + close_idx);
return el_wrap_str(b.buf);
}
el_val_t json_array_len(el_val_t json_str) {
const char* s = EL_CSTR(json_str);
if (!s) return 0;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s != '[') return 0;
s++;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ']') return 0;
int64_t count = 0;
while (*s) {
const char* end = json_skip_value(s);
if (end == s) break;
count++;
s = end;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ',') { s++; continue; }
if (*s == ']' || *s == '\0') break;
}
return (el_val_t)count;
}
/* json_array_get — return the i-th element of a JSON array as a JSON
* fragment string. Nested objects and arrays are returned verbatim
* (json_skip_value tracks brace/bracket depth so nested structures are
* preserved intact). Out-of-range index → "". */
el_val_t json_array_get(el_val_t json_str, el_val_t index) {
const char* s = EL_CSTR(json_str);
int64_t idx = (int64_t)index;
if (!s || idx < 0) return el_wrap_str(el_strdup(""));
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s != '[') return el_wrap_str(el_strdup(""));
s++;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ']') return el_wrap_str(el_strdup(""));
int64_t i = 0;
while (*s) {
const char* start = s;
const char* end = json_skip_value(s);
if (end == s) break;
if (i == idx) {
size_t n = (size_t)(end - start);
char* out = el_strbuf(n);
memcpy(out, start, n);
out[n] = '\0';
return el_wrap_str(out);
}
i++;
s = end;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ',') { s++; while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; continue; }
if (*s == ']' || *s == '\0') break;
}
return el_wrap_str(el_strdup(""));
}
/* json_array_get_string — same as json_array_get, but assume the element
* is a JSON string and return the unquoted/unescaped value. Non-string
* elements yield "". */
el_val_t json_array_get_string(el_val_t json_str, el_val_t index) {
el_val_t raw = json_array_get(json_str, index);
const char* s = EL_CSTR(raw);
if (!s || *s != '"') return el_wrap_str(el_strdup(""));
JsonParser jp = {
.p = s,
.end = s + strlen(s),
.err = 0,
};
char* parsed = jp_parse_string_raw(&jp);
if (jp.err) {
free(parsed);
return el_wrap_str(el_strdup(""));
}
return el_wrap_str(parsed);
}
/* json_escape_string — escape a string value for embedding in JSON.
* Returns the escaped content WITHOUT surrounding quotes.
* "say \"hello\"" -> "say \\\"hello\\\"" */
el_val_t json_escape_string(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
/* Worst case: every char needs a 2-char escape. */
char* out = malloc(n * 2 + 1);
if (!out) return el_wrap_str(el_strdup(""));
size_t j = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '"') { out[j++] = '\\'; out[j++] = '"'; }
else if (c == '\\') { out[j++] = '\\'; out[j++] = '\\'; }
else if (c == '\n') { out[j++] = '\\'; out[j++] = 'n'; }
else if (c == '\r') { out[j++] = '\\'; out[j++] = 'r'; }
else if (c == '\t') { out[j++] = '\\'; out[j++] = 't'; }
else { out[j++] = (char)c; }
}
out[j] = '\0';
el_val_t result = el_wrap_str(el_strdup(out));
free(out);
return result;
}
/* json_build_object — build a JSON object from a flat key-value list.
* kvs is [key0, val0, key1, val1, ...]. Values are raw JSON (pass
* strings as "\"value\"" or use json_escape_string). */
el_val_t json_build_object(el_val_t kvs) {
el_val_t list = kvs;
int64_t n = el_list_len(list);
JsonBuf b; jb_init(&b);
jb_putc(&b, '{');
int first = 1;
for (int64_t i = 0; i + 1 < n; i += 2) {
el_val_t k = el_list_get(list, (el_val_t)i);
el_val_t v = el_list_get(list, (el_val_t)(i + 1));
const char* ks = EL_CSTR(k);
const char* vs = EL_CSTR(v);
if (!ks || !vs) continue;
if (!first) jb_putc(&b, ',');
first = 0;
jb_putc(&b, '"');
jb_puts(&b, ks);
jb_puts(&b, "\":\"");
/* escape the value string */
size_t vn = strlen(vs);
for (size_t j = 0; j < vn; j++) {
unsigned char c = (unsigned char)vs[j];
if (c == '"') { jb_putc(&b, '\\'); jb_putc(&b, '"'); }
else if (c == '\\') { jb_putc(&b, '\\'); jb_putc(&b, '\\'); }
else if (c == '\n') { jb_putc(&b, '\\'); jb_putc(&b, 'n'); }
else if (c == '\r') { jb_putc(&b, '\\'); jb_putc(&b, 'r'); }
else if (c == '\t') { jb_putc(&b, '\\'); jb_putc(&b, 't'); }
else { jb_putc(&b, (char)c); }
}
jb_putc(&b, '"');
}
jb_putc(&b, '}');
return el_wrap_str(b.buf);
}
/* json_build_array — build a JSON array from a list of raw JSON values.
* items is ["\"alpha\"", "\"beta\"", "42", "true", ...]. */
el_val_t json_build_array(el_val_t items) {
el_val_t list = items;
int64_t n = el_list_len(list);
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
for (int64_t i = 0; i < n; i++) {
el_val_t v = el_list_get(list, (el_val_t)i);
const char* vs = EL_CSTR(v);
if (!vs) continue;
if (i > 0) jb_putc(&b, ',');
jb_puts(&b, vs);
}
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
/* ── Time ────────────────────────────────────────────────────────────────── */
el_val_t time_now(void) {
struct timeval tv;
gettimeofday(&tv, NULL);
int64_t ms = (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
return (el_val_t)ms;
}
el_val_t time_now_utc(void) {
return time_now();
}
el_val_t time_format(el_val_t ts, el_val_t fmt) {
int64_t ms = (int64_t)ts;
time_t s = (time_t)(ms / 1000);
int msec = (int)(ms % 1000);
if (msec < 0) { msec += 1000; s -= 1; }
struct tm tm;
gmtime_r(&s, &tm);
const char* fmt_str = EL_CSTR(fmt);
if (!fmt_str || *fmt_str == '\0' || strcmp(fmt_str, "ISO") == 0) {
char buf[64];
snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
return el_wrap_str(el_strdup(buf));
}
char buf[256];
if (strftime(buf, sizeof(buf), fmt_str, &tm) == 0) buf[0] = '\0';
return el_wrap_str(el_strdup(buf));
}
el_val_t time_to_parts(el_val_t ts) {
int64_t ms = (int64_t)ts;
time_t s = (time_t)(ms / 1000);
int msec = (int)(ms % 1000);
if (msec < 0) { msec += 1000; s -= 1; }
struct tm tm;
gmtime_r(&s, &tm);
/* Return a JSON string so callers can use json_get to extract fields. */
char buf[256];
snprintf(buf, sizeof(buf),
"{\"year\":%d,\"month\":%d,\"day\":%d,\"hour\":%d,\"minute\":%d,\"second\":%d,\"ms\":%d}",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
return el_wrap_str(el_strdup(buf));
}
el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz) {
(void)tz;
int64_t s = (int64_t)secs;
int64_t n = (int64_t)ns;
int64_t ms = s * 1000LL + n / 1000000LL;
return (el_val_t)ms;
}
el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit) {
const char* u = EL_CSTR(unit);
int64_t cur = (int64_t)ts;
int64_t d = (int64_t)n;
int64_t add_ms = d;
if (u) {
if (strcmp(u, "ms") == 0) add_ms = d;
else if (strcmp(u, "sec") == 0) add_ms = d * 1000LL;
else if (strcmp(u, "min") == 0) add_ms = d * 60000LL;
else if (strcmp(u, "hour") == 0) add_ms = d * 3600000LL;
else if (strcmp(u, "day") == 0) add_ms = d * 86400000LL;
}
return (el_val_t)(cur + add_ms);
}
el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit) {
int64_t d = (int64_t)ts2 - (int64_t)ts1;
const char* u = EL_CSTR(unit);
if (!u || strcmp(u, "ms") == 0) return (el_val_t)d;
if (strcmp(u, "sec") == 0) return (el_val_t)(d / 1000LL);
if (strcmp(u, "min") == 0) return (el_val_t)(d / 60000LL);
if (strcmp(u, "hour") == 0) return (el_val_t)(d / 3600000LL);
if (strcmp(u, "day") == 0) return (el_val_t)(d / 86400000LL);
return (el_val_t)d;
}
/* Block the calling thread for `secs` seconds. Negative values are clamped
* to 0. Used by El programs that poll external resources (e.g. RunPod
* /status, Engram readiness probes). */
el_val_t sleep_secs(el_val_t secs) {
int64_t s = (int64_t)secs;
if (s < 0) s = 0;
struct timespec ts;
ts.tv_sec = (time_t)s;
ts.tv_nsec = 0;
nanosleep(&ts, NULL);
return 0;
}
el_val_t sleep_ms(el_val_t ms) {
int64_t m = (int64_t)ms;
if (m < 0) m = 0;
struct timespec ts;
ts.tv_sec = (time_t)(m / 1000LL);
ts.tv_nsec = (long)((m % 1000LL) * 1000000LL);
nanosleep(&ts, NULL);
return 0;
}
/* ── Instant + Duration: first-class temporal types ──────────────────────────
* El's substrate (Neuron) is a temporal cognition system. Memory salience
* decay, the six-tier pacemaker, TTL caches, and supersession are all
* temporal. Treating time as a raw Int (now() returning ms-since-epoch and
* arithmetic done with mixed unit literals) lets bugs through the type
* system: `(now - cached_at) < 60` cannot tell ms from sec, and `sleep(30)`
* is ambiguous. This block introduces two dedicated representations.
*
* Representation:
* Instant — int64 nanoseconds since the Unix epoch
* Duration — int64 nanoseconds (signed; negative durations are legal,
* e.g. when a deadline has passed)
*
* Both share the el_val_t (int64) slot the rest of the runtime uses, so no
* boxing / arena allocation is needed. Type discipline is enforced at the
* codegen layer: `let x: Duration = ...` registers `x` in __duration_names,
* and BinOp dispatches through typed wrappers (el_duration_add, etc.) that
* make intent explicit in the generated C. Mismatched ops (Instant+Instant,
* Duration+Int) are surfaced via #error directives at codegen time so the
* downstream cc step fails with a clear El-source-level message.
*
* Nanosecond precision matches POSIX clock_gettime / nanosleep granularity.
* 2^63 nanos covers ~292 years from epoch — comfortably past 2200, plenty
* for a memory-system runtime that never schedules outside a human lifespan.
*/
/* now() — current Instant. Wraps clock_gettime(CLOCK_REALTIME) for nanosecond
* precision. Falls back to gettimeofday on systems where clock_gettime is
* unavailable (defensive — every supported platform has it). */
el_val_t el_now_instant(void) {
struct timespec ts;
if (clock_gettime(CLOCK_REALTIME, &ts) == 0) {
int64_t ns = (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec;
return (el_val_t)ns;
}
struct timeval tv;
gettimeofday(&tv, NULL);
int64_t ns = (int64_t)tv.tv_sec * 1000000000LL
+ (int64_t)tv.tv_usec * 1000LL;
return (el_val_t)ns;
}
el_val_t now(void) {
return el_now_instant();
}
/* now_ns — return current Unix time as nanoseconds (Int).
* Thin wrapper over el_now_instant for use in test timing. */
el_val_t now_ns(void) {
return el_now_instant();
}
/* unix_seconds(n) — Instant from a Unix-epoch second count.
* unix_millis(n) — Instant from a Unix-epoch millisecond count. */
el_val_t unix_seconds(el_val_t n) {
int64_t s = (int64_t)n;
return (el_val_t)(s * 1000000000LL);
}
el_val_t unix_millis(el_val_t n) {
int64_t m = (int64_t)n;
return (el_val_t)(m * 1000000LL);
}
/* instant_from_iso8601 — parse a strict subset:
* YYYY-MM-DDTHH:MM:SS[.fff]Z
* Returns 0 (the Unix-epoch sentinel) on parse failure. Callers that need to
* distinguish epoch-zero from a parse error should use a wider sentinel
* representation; the current zero-on-failure choice matches existing El
* runtime conventions for parse builtins (str_to_int, parse_int). */
el_val_t instant_from_iso8601(el_val_t s) {
const char* str = EL_CSTR(s);
if (!str) return (el_val_t)0;
int Y, M, D, h, m, sec, frac = 0;
int n = sscanf(str, "%d-%d-%dT%d:%d:%d.%3d", &Y, &M, &D, &h, &m, &sec, &frac);
if (n < 6) {
n = sscanf(str, "%d-%d-%dT%d:%d:%dZ", &Y, &M, &D, &h, &m, &sec);
if (n < 6) return (el_val_t)0;
}
struct tm tm;
memset(&tm, 0, sizeof(tm));
tm.tm_year = Y - 1900;
tm.tm_mon = M - 1;
tm.tm_mday = D;
tm.tm_hour = h;
tm.tm_min = m;
tm.tm_sec = sec;
/* timegm — UTC. POSIX-Y but available on macOS and glibc. */
time_t t = timegm(&tm);
if (t == (time_t)-1) return (el_val_t)0;
int64_t ns = (int64_t)t * 1000000000LL + (int64_t)frac * 1000000LL;
return (el_val_t)ns;
}
/* Duration constructors. The El-side postfix literals (30.seconds, 1.hour)
* are lowered by the codegen directly into a literal int64 of nanoseconds —
* these constructors are for runtime values where the count is dynamic. */
el_val_t el_duration_from_nanos(el_val_t ns) {
return (el_val_t)(int64_t)ns;
}
el_val_t duration_seconds(el_val_t n) {
int64_t s = (int64_t)n;
return (el_val_t)(s * 1000000000LL);
}
el_val_t duration_millis(el_val_t n) {
int64_t m = (int64_t)n;
return (el_val_t)(m * 1000000LL);
}
el_val_t duration_nanos(el_val_t n) {
return (el_val_t)(int64_t)n;
}
/* Arithmetic — typed wrappers. At the C level these are no-op casts, but
* the codegen routes Instant/Duration BinOps through them so the generated
* C says `el_instant_add_dur(start, dur)` rather than `start + dur`. The
* intent is explicit, the operand order is documented, and a future change
* to the underlying representation (saturating arithmetic, overflow guards)
* has a single chokepoint. */
el_val_t el_instant_add_dur(el_val_t inst, el_val_t dur) {
return (el_val_t)((int64_t)inst + (int64_t)dur);
}
el_val_t el_instant_sub_dur(el_val_t inst, el_val_t dur) {
return (el_val_t)((int64_t)inst - (int64_t)dur);
}
el_val_t el_instant_diff(el_val_t a, el_val_t b) {
/* a - b — yields a Duration (negative if b is later than a). */
return (el_val_t)((int64_t)a - (int64_t)b);
}
el_val_t el_duration_add(el_val_t a, el_val_t b) {
return (el_val_t)((int64_t)a + (int64_t)b);
}
el_val_t el_duration_sub(el_val_t a, el_val_t b) {
return (el_val_t)((int64_t)a - (int64_t)b);
}
el_val_t el_duration_scale(el_val_t dur, el_val_t scalar) {
return (el_val_t)((int64_t)dur * (int64_t)scalar);
}
el_val_t el_duration_div(el_val_t dur, el_val_t scalar) {
int64_t s = (int64_t)scalar;
if (s == 0) return (el_val_t)0;
return (el_val_t)((int64_t)dur / s);
}
/* Comparisons. Return 1/0 in el_val_t convention. */
el_val_t el_instant_lt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a < (int64_t)b ? 1 : 0); }
el_val_t el_instant_le(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a <= (int64_t)b ? 1 : 0); }
el_val_t el_instant_gt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a > (int64_t)b ? 1 : 0); }
el_val_t el_instant_ge(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a >= (int64_t)b ? 1 : 0); }
el_val_t el_instant_eq(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a == (int64_t)b ? 1 : 0); }
el_val_t el_instant_ne(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a != (int64_t)b ? 1 : 0); }
el_val_t el_duration_lt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a < (int64_t)b ? 1 : 0); }
el_val_t el_duration_le(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a <= (int64_t)b ? 1 : 0); }
el_val_t el_duration_gt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a > (int64_t)b ? 1 : 0); }
el_val_t el_duration_ge(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a >= (int64_t)b ? 1 : 0); }
el_val_t el_duration_eq(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a == (int64_t)b ? 1 : 0); }
el_val_t el_duration_ne(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a != (int64_t)b ? 1 : 0); }
/* Conversions. */
el_val_t instant_to_unix_seconds(el_val_t i) {
return (el_val_t)((int64_t)i / 1000000000LL);
}
el_val_t instant_to_unix_millis(el_val_t i) {
return (el_val_t)((int64_t)i / 1000000LL);
}
el_val_t instant_to_iso8601(el_val_t i) {
int64_t ns = (int64_t)i;
time_t s = (time_t)(ns / 1000000000LL);
int msec = (int)((ns / 1000000LL) % 1000LL);
if (msec < 0) { msec += 1000; s -= 1; }
struct tm tm;
gmtime_r(&s, &tm);
char buf[64];
snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
return el_wrap_str(el_strdup(buf));
}
el_val_t duration_to_seconds(el_val_t d) {
return (el_val_t)((int64_t)d / 1000000000LL);
}
el_val_t duration_to_millis(el_val_t d) {
return (el_val_t)((int64_t)d / 1000000LL);
}
el_val_t duration_to_nanos(el_val_t d) {
return (el_val_t)(int64_t)d;
}
/* sleep(Duration) — Phase 1 replacement for ambiguous sleep(Int). The runtime
* still exposes sleep_secs/sleep_ms for legacy call sites; codegen lowers
* sleep(Duration) to el_sleep_duration(d). Negative durations clamp to 0 so a
* stale deadline doesn't block forever. */
el_val_t el_sleep_duration(el_val_t dur) {
int64_t ns = (int64_t)dur;
if (ns < 0) ns = 0;
struct timespec ts;
ts.tv_sec = (time_t)(ns / 1000000000LL);
ts.tv_nsec = (long)(ns % 1000000000LL);
nanosleep(&ts, NULL);
return (el_val_t)0;
}
/* unix_timestamp() — back-compat. Existing El callers expect an Int seconds
* value; this stays an Int returner so the type system isn't disturbed for
* legacy code. New code should call now() and convert when needed. */
el_val_t unix_timestamp(void) {
return instant_to_unix_seconds(el_now_instant());
}
/* TTL cache helpers. Backed by the existing process-wide K/V (state_set/get)
* with a sibling __ttl_set_at_<key> entry recording the Instant of the last
* write. ttl_cache_get returns "" if the entry is missing or stale, so call
* sites can branch on `if v == "" { miss } else { hit }` — the same shape
* existing get-with-default code uses. No more (now - cached_at) < 60. */
el_val_t ttl_cache_set(el_val_t key, el_val_t value) {
const char* k = EL_CSTR(key);
if (!k) return (el_val_t)0;
/* Store the value at the user's key. */
state_set(key, value);
/* Stamp set_at — opaque schema, namespaced under __ttl: prefix so user
* keys can't collide with stamps. */
size_t klen = strlen(k);
char* stamp_key = (char*)malloc(klen + 16);
if (!stamp_key) return (el_val_t)0;
snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
int64_t now_ns = (int64_t)el_now_instant();
char buf[32];
snprintf(buf, sizeof(buf), "%lld", (long long)now_ns);
state_set(EL_STR(stamp_key), EL_STR(buf));
free(stamp_key);
return (el_val_t)1;
}
el_val_t ttl_cache_get(el_val_t key, el_val_t max_age) {
const char* k = EL_CSTR(key);
if (!k) return el_wrap_str(el_strdup(""));
/* Look up stamp. */
size_t klen = strlen(k);
char* stamp_key = (char*)malloc(klen + 16);
if (!stamp_key) return el_wrap_str(el_strdup(""));
snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
el_val_t stamp = state_get(EL_STR(stamp_key));
free(stamp_key);
const char* sv = EL_CSTR(stamp);
if (!sv || !*sv) return el_wrap_str(el_strdup(""));
int64_t set_at = (int64_t)atoll(sv);
int64_t now_ns = (int64_t)el_now_instant();
int64_t age = now_ns - set_at;
int64_t max_ns = (int64_t)max_age;
if (age < 0) return el_wrap_str(el_strdup("")); /* clock skew — treat as miss */
if (age > max_ns) return el_wrap_str(el_strdup("")); /* expired */
return state_get(key);
}
el_val_t ttl_cache_age(el_val_t key) {
const char* k = EL_CSTR(key);
if (!k) return (el_val_t)INT64_MAX;
size_t klen = strlen(k);
char* stamp_key = (char*)malloc(klen + 16);
if (!stamp_key) return (el_val_t)INT64_MAX;
snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
el_val_t stamp = state_get(EL_STR(stamp_key));
free(stamp_key);
const char* sv = EL_CSTR(stamp);
if (!sv || !*sv) return (el_val_t)INT64_MAX;
int64_t set_at = (int64_t)atoll(sv);
int64_t now_ns = (int64_t)el_now_instant();
return (el_val_t)(now_ns - set_at);
}
/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ──────────────
* Phase 1.5. Calendar is pluggable: EarthCalendar (IANA zones + Gregorian +
* DST), MarsCalendar (sols, MTC), CycleCalendar(period), NoCycleCalendar,
* RelativeCalendar(epoch). Phase 1 zone wrapping folds INTO EarthCalendar;
* UTC and IANA zones are themselves Earth-parochial and cannot live at the
* lowest type layer.
*
* A Rhythm is a small AST that asks the Calendar for cycle phase, weekday,
* etc. Most rhythm logic is calendar-agnostic at runtime: rhythm_cycle_phase
* means "midpoint of cycle" whether the cycle is 24h on Earth or 30h on a
* station or 300y on a long-cycle world. */
/* Magic headers — used by the runtime to recognize boxed temporal values
* arriving through el_val_t. Distinct constants so accidental misuse fails
* loudly rather than silently. */
#define EL_CAL_MAGIC 0xE1CA1EDDU
#define EL_CALTIME_MAGIC 0xE1CA1747U
#define EL_RHYTHM_MAGIC 0xE1287A11U
#define EL_LDATE_MAGIC 0xE1DA7E00U
#define EL_LDT_MAGIC 0xE1DA7E1DU
#define EL_ZONE_MAGIC 0xE12017E0U
typedef enum {
EL_CALENDAR_EARTH = 1,
EL_CALENDAR_MARS = 2,
EL_CALENDAR_CYCLE = 3,
EL_CALENDAR_NO_CYCLE = 4,
EL_CALENDAR_RELATIVE = 5
} el_calendar_kind_t;
typedef struct {
uint32_t magic;
char* id; /* IANA name or "+HH:MM" / "-HH:MM" */
int fixed; /* 1 for fixed offset, 0 for IANA */
int64_t offset_ns; /* fixed offset in nanos (only when fixed) */
} el_zone_t;
typedef struct {
uint32_t magic;
el_calendar_kind_t kind;
el_zone_t* zone; /* EarthCalendar; MarsCalendar uses MTC */
int64_t cycle_period_ns;/* CycleCalendar; computed for Earth (86400 s) and Mars (88775.244 s) */
int64_t epoch_ns; /* RelativeCalendar; Unix-epoch zero otherwise */
} el_calendar_t;
typedef struct {
uint32_t magic;
int64_t instant_ns;
el_calendar_t* cal;
} el_caltime_t;
/* Rhythm AST. */
typedef enum {
EL_RHYTHM_CYCLE_START = 1,
EL_RHYTHM_CYCLE_PHASE = 2,
EL_RHYTHM_DURATION = 3,
EL_RHYTHM_SESSION_START = 4,
EL_RHYTHM_EVENT = 5,
EL_RHYTHM_AND = 6,
EL_RHYTHM_OR = 7,
EL_RHYTHM_WEEKDAY = 8,
EL_RHYTHM_WEEKLY_AT = 9
} el_rhythm_kind_t;
typedef struct el_rhythm_s {
uint32_t magic;
el_rhythm_kind_t kind;
double phase; /* CYCLE_PHASE */
int64_t period_ns; /* DURATION */
int weekday; /* 1..7 Mon..Sun */
int hour;
int minute;
char* event_name; /* EVENT */
struct el_rhythm_s* a; /* AND/OR */
struct el_rhythm_s* b;
} el_rhythm_t;
typedef struct {
uint32_t magic;
int year;
int month;
int day;
} el_localdate_t;
typedef struct {
uint32_t magic;
el_localdate_t* date;
int64_t time_ns; /* nanos since midnight */
} el_localdt_t;
/* Magic-tag check helpers — peek the first 4 bytes of an el_val_t pointer
* and compare against the expected magic. Strings are NUL-terminated and
* never start with our magic byte sequence, so this is safe. */
static int el_is_magic(el_val_t v, uint32_t want) {
if (v == 0) return 0;
/* Defensive: only follow pointers in plausible address space.
* On 64-bit unix processes pointers are above 0x10000. */
if ((uint64_t)v < 0x10000ULL) return 0;
uint32_t got = *(volatile uint32_t*)(uintptr_t)v;
return got == want;
}
/* Sol length on Mars in nanoseconds: 88775.244 seconds. */
#define EL_MARS_SOL_NS ((int64_t)88775244000000LL)
/* Earth solar day in nanoseconds: 86400 seconds. */
#define EL_EARTH_DAY_NS ((int64_t)86400000000000LL)
/* ── Zone construction ──────────────────────────────────────────────────────
* Zones intern by id string so equality comparisons are pointer-compares. */
#define EL_ZONE_TABLE_CAP 64
static el_zone_t* _el_zone_table[EL_ZONE_TABLE_CAP];
static int _el_zone_count = 0;
static el_zone_t* _el_zone_intern(const char* id, int fixed, int64_t offset_ns) {
for (int i = 0; i < _el_zone_count; i++) {
el_zone_t* z = _el_zone_table[i];
if (z->fixed == fixed && z->offset_ns == offset_ns &&
strcmp(z->id ? z->id : "", id ? id : "") == 0) {
return z;
}
}
if (_el_zone_count >= EL_ZONE_TABLE_CAP) {
/* Out of slots: build a non-interned zone. Equality will fail across
* such zones but the program still runs. */
el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
z->magic = EL_ZONE_MAGIC;
z->id = el_strdup_persist(id ? id : "");
z->fixed = fixed;
z->offset_ns = offset_ns;
return z;
}
el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
z->magic = EL_ZONE_MAGIC;
z->id = el_strdup_persist(id ? id : "");
z->fixed = fixed;
z->offset_ns = offset_ns;
_el_zone_table[_el_zone_count++] = z;
return z;
}
el_val_t zone(el_val_t id) {
const char* s = EL_CSTR(id);
if (!s || !*s) return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 0, 0);
/* Fixed-offset shortcut: "+HH:MM" or "-HH:MM". */
if ((s[0] == '+' || s[0] == '-') && strlen(s) >= 6 && s[3] == ':') {
int sign = (s[0] == '-') ? -1 : 1;
int hh = (s[1] - '0') * 10 + (s[2] - '0');
int mm = (s[4] - '0') * 10 + (s[5] - '0');
int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
return (el_val_t)(uintptr_t)_el_zone_intern(s, 1, off);
}
return (el_val_t)(uintptr_t)_el_zone_intern(s, 0, 0);
}
el_val_t zone_utc(void) {
return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 1, 0);
}
el_val_t zone_local(void) {
/* Resolve the local zone via TZ env or system default. tzset() picks
* up TZ if set; otherwise the C library reads /etc/localtime. We store
* the zone id as "LOCAL" so subsequent equality holds; resolution is
* lazy at use time. */
return (el_val_t)(uintptr_t)_el_zone_intern("LOCAL", 0, 0);
}
el_val_t zone_offset(el_val_t hours, el_val_t minutes) {
int hh = (int)(int64_t)hours;
int mm = (int)(int64_t)minutes;
int sign = (hh < 0 || mm < 0) ? -1 : 1;
if (hh < 0) hh = -hh;
if (mm < 0) mm = -mm;
int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
char buf[16];
snprintf(buf, sizeof(buf), "%c%02d:%02d", sign < 0 ? '-' : '+', hh, mm);
return (el_val_t)(uintptr_t)_el_zone_intern(buf, 1, off);
}
/* ── Calendar interning ──────────────────────────────────────────────────── */
#define EL_CAL_TABLE_CAP 64
static el_calendar_t* _el_cal_table[EL_CAL_TABLE_CAP];
static int _el_cal_count = 0;
static el_calendar_t* _el_cal_intern(el_calendar_kind_t kind, el_zone_t* z,
int64_t period_ns, int64_t epoch_ns) {
for (int i = 0; i < _el_cal_count; i++) {
el_calendar_t* c = _el_cal_table[i];
if (c->kind == kind && c->zone == z &&
c->cycle_period_ns == period_ns && c->epoch_ns == epoch_ns) {
return c;
}
}
el_calendar_t* c = (el_calendar_t*)malloc(sizeof(el_calendar_t));
c->magic = EL_CAL_MAGIC;
c->kind = kind;
c->zone = z;
c->cycle_period_ns = period_ns;
c->epoch_ns = epoch_ns;
if (_el_cal_count < EL_CAL_TABLE_CAP) _el_cal_table[_el_cal_count++] = c;
return c;
}
el_val_t earth_calendar(el_val_t z_val) {
el_zone_t* z = NULL;
if (z_val != 0 && el_is_magic(z_val, EL_ZONE_MAGIC)) {
z = (el_zone_t*)(uintptr_t)z_val;
} else {
z = (el_zone_t*)(uintptr_t)zone_local();
}
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_EARTH, z, EL_EARTH_DAY_NS, 0);
}
el_val_t earth_calendar_default(void) {
return earth_calendar(zone_local());
}
el_val_t mars_calendar(void) {
el_zone_t* z = (el_zone_t*)(uintptr_t)_el_zone_intern("MTC", 1, 0);
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_MARS, z, EL_MARS_SOL_NS, 0);
}
el_val_t cycle_calendar(el_val_t period_dur) {
int64_t period = (int64_t)period_dur;
if (period <= 0) period = 1;
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_CYCLE, NULL, period, 0);
}
el_val_t no_cycle_calendar(void) {
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_NO_CYCLE, NULL, 0, 0);
}
el_val_t relative_calendar(el_val_t epoch_inst) {
int64_t ep = (int64_t)epoch_inst;
return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_RELATIVE, NULL, 0, ep);
}
/* ── CalendarTime ───────────────────────────────────────────────────────── */
static el_caltime_t* _el_caltime_alloc(int64_t inst, el_calendar_t* c) {
el_caltime_t* ct = (el_caltime_t*)malloc(sizeof(el_caltime_t));
ct->magic = EL_CALTIME_MAGIC;
ct->instant_ns = inst;
ct->cal = c;
return ct;
}
static el_calendar_t* _el_resolve_cal(el_val_t cal_val) {
if (cal_val == 0 || !el_is_magic(cal_val, EL_CAL_MAGIC)) {
return (el_calendar_t*)(uintptr_t)earth_calendar_default();
}
return (el_calendar_t*)(uintptr_t)cal_val;
}
el_val_t now_in(el_val_t cal_val) {
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t ns = (int64_t)el_now_instant();
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
el_val_t in_calendar(el_val_t inst, el_val_t cal_val) {
el_calendar_t* c = _el_resolve_cal(cal_val);
return (el_val_t)(uintptr_t)_el_caltime_alloc((int64_t)inst, c);
}
el_val_t cal_to_instant(el_val_t ct_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
return (el_val_t)ct->instant_ns;
}
el_val_t cal_in(el_val_t ct_val, el_val_t cal_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ct->instant_ns, c);
}
el_val_t cal_cycle_phase(el_val_t ct_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_from_float(0.0);
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
el_calendar_t* c = ct->cal;
if (c->kind == EL_CALENDAR_NO_CYCLE) {
return el_from_float(0.0/0.0); /* NaN sentinel */
}
int64_t period = c->cycle_period_ns;
if (period <= 0) return el_from_float(0.0);
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t phase_ns = base % period;
if (phase_ns < 0) phase_ns += period;
double phase = (double)phase_ns / (double)period;
return el_from_float(phase);
}
/* ── Earth zone resolution: TZ-based offset lookup ──────────────────────────
* For an EarthCalendar(zone), we want to convert an instant_ns into local
* y/m/d/h/m/s, including DST. Approach: setenv("TZ", id), tzset(), use
* localtime_r, then restore. This is not thread-safe by design — El's
* runtime is single-threaded for the request handler path. Cache the
* computed (instant -> tm) to avoid the syscall churn on repeat formats. */
static void _el_apply_zone(el_zone_t* z) {
if (!z) { unsetenv("TZ"); tzset(); return; }
if (z->fixed && strcmp(z->id, "UTC") == 0) {
setenv("TZ", "UTC0", 1);
tzset();
return;
}
if (z->fixed) {
/* Fixed offset: POSIX TZ uses inverted sign (sign convention of
* "hours WEST of UTC" rather than east). Build the spec accordingly. */
char buf[32];
int neg_secs = (int)(-z->offset_ns / 1000000000LL);
int sign = neg_secs < 0 ? -1 : 1;
int abs_secs = neg_secs < 0 ? -neg_secs : neg_secs;
int hh = abs_secs / 3600;
int mm = (abs_secs % 3600) / 60;
snprintf(buf, sizeof(buf), "FIX%c%d:%02d", sign < 0 ? '-' : '+', hh, mm);
setenv("TZ", buf, 1);
tzset();
return;
}
if (strcmp(z->id, "LOCAL") == 0) {
unsetenv("TZ");
tzset();
return;
}
setenv("TZ", z->id, 1);
tzset();
}
static int _el_decompose_earth(el_caltime_t* ct, struct tm* tm_out, int* abbr_len, char* abbr_buf, size_t abbr_cap) {
el_calendar_t* c = ct->cal;
el_zone_t* z = c->zone;
_el_apply_zone(z);
time_t s = (time_t)(ct->instant_ns / 1000000000LL);
struct tm tm;
localtime_r(&s, &tm);
*tm_out = tm;
if (abbr_buf && abbr_cap > 0) {
const char* z_str = tm.tm_zone ? tm.tm_zone : "";
size_t n = strlen(z_str);
if (n >= abbr_cap) n = abbr_cap - 1;
memcpy(abbr_buf, z_str, n);
abbr_buf[n] = '\0';
if (abbr_len) *abbr_len = (int)n;
}
return 0;
}
/* Format an Earth CalendarTime under a Java-DateTimeFormatter-ish pattern.
* We support a useful core: yyyy MM dd HH mm ss z EEE MMM d h a — enough for
* the acceptance tests. Single quotes denote literal text. */
static const char* _el_weekday_short[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* _el_month_short[] = {"Jan","Feb","Mar","Apr","May","Jun",
"Jul","Aug","Sep","Oct","Nov","Dec"};
static char* _el_format_earth(el_caltime_t* ct, const char* pattern) {
struct tm tm;
char abbr[16] = {0};
int abbr_len = 0;
_el_decompose_earth(ct, &tm, &abbr_len, abbr, sizeof(abbr));
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
size_t i = 0;
size_t plen = strlen(pattern);
while (i < plen) {
char ch = pattern[i];
/* Quoted literal */
if (ch == '\'') {
i++;
while (i < plen && pattern[i] != '\'') {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i++];
}
if (i < plen) i++;
continue;
}
/* Count run of same letter */
size_t run = 1;
while (i + run < plen && pattern[i + run] == ch) run++;
char tmp[64];
tmp[0] = '\0';
if (ch == 'y') {
if (run >= 4) snprintf(tmp, sizeof(tmp), "%04d", tm.tm_year + 1900);
else snprintf(tmp, sizeof(tmp), "%02d", (tm.tm_year + 1900) % 100);
} else if (ch == 'M') {
if (run >= 3) snprintf(tmp, sizeof(tmp), "%s", _el_month_short[tm.tm_mon]);
else if (run == 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mon + 1);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mon + 1);
} else if (ch == 'd') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mday);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mday);
} else if (ch == 'H') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_hour);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_hour);
} else if (ch == 'h') {
int h12 = tm.tm_hour % 12; if (h12 == 0) h12 = 12;
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", h12);
else snprintf(tmp, sizeof(tmp), "%d", h12);
} else if (ch == 'm') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_min);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_min);
} else if (ch == 's') {
if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_sec);
else snprintf(tmp, sizeof(tmp), "%d", tm.tm_sec);
} else if (ch == 'a') {
snprintf(tmp, sizeof(tmp), "%s", tm.tm_hour < 12 ? "AM" : "PM");
} else if (ch == 'E') {
snprintf(tmp, sizeof(tmp), "%s", _el_weekday_short[tm.tm_wday]);
} else if (ch == 'z') {
snprintf(tmp, sizeof(tmp), "%s", abbr);
} else {
for (size_t k = 0; k < run; k++) {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = ch;
}
i += run;
continue;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
i += run;
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
/* Format a Mars CalendarTime: %sol prints the integer sol number since
* mission epoch (Unix epoch fallback), %phase prints cycle_phase as a
* 0..1 decimal. Other %-specifiers fall through. */
static char* _el_format_mars(el_caltime_t* ct, const char* pattern) {
el_calendar_t* c = ct->cal;
int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : EL_MARS_SOL_NS;
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t sol = base / period;
int64_t phase_ns = base % period;
if (phase_ns < 0) { phase_ns += period; sol -= 1; }
double phase = (double)phase_ns / (double)period;
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
for (size_t i = 0; pattern[i]; i++) {
if (pattern[i] == '%' && pattern[i+1]) {
char tmp[64];
tmp[0] = '\0';
if (strncmp(pattern + i + 1, "sol", 3) == 0) {
snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
i += 3;
} else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%.4f", phase);
i += 5;
} else if (pattern[i+1] == 'd') {
snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
i += 1;
} else {
tmp[0] = pattern[i+1]; tmp[1] = '\0';
i += 1;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
} else {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i];
}
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
/* Format a CycleCalendar CalendarTime: %cycle and %phase. */
static char* _el_format_cycle(el_caltime_t* ct, const char* pattern) {
el_calendar_t* c = ct->cal;
int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : 1;
int64_t base = ct->instant_ns - c->epoch_ns;
int64_t cycle = base / period;
int64_t phase_ns = base % period;
if (phase_ns < 0) { phase_ns += period; cycle -= 1; }
double phase = (double)phase_ns / (double)period;
size_t cap = strlen(pattern) * 4 + 64;
char* out = (char*)malloc(cap);
size_t pos = 0;
for (size_t i = 0; pattern[i]; i++) {
if (pattern[i] == '%' && pattern[i+1]) {
char tmp[64];
tmp[0] = '\0';
if (strncmp(pattern + i + 1, "cycle", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
i += 5;
} else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
snprintf(tmp, sizeof(tmp), "%.4f", phase);
i += 5;
} else if (pattern[i+1] == 'd') {
snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
i += 1;
} else if (pattern[i+1] == 'f') {
snprintf(tmp, sizeof(tmp), "%.2f", phase);
i += 1;
} else {
/* Pass through unknown specifier */
tmp[0] = '%'; tmp[1] = pattern[i+1]; tmp[2] = '\0';
i += 1;
}
size_t tl = strlen(tmp);
if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
memcpy(out + pos, tmp, tl);
pos += tl;
} else {
if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
out[pos++] = pattern[i];
}
}
out[pos] = '\0';
char* result = el_strdup(out);
free(out);
return result;
}
el_val_t cal_format(el_val_t ct_val, el_val_t pattern_val) {
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_wrap_str(el_strdup(""));
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
const char* pat = EL_CSTR(pattern_val);
if (!pat) pat = "";
char* result = NULL;
switch (ct->cal->kind) {
case EL_CALENDAR_EARTH: result = _el_format_earth(ct, pat); break;
case EL_CALENDAR_MARS: result = _el_format_mars(ct, pat); break;
case EL_CALENDAR_CYCLE: result = _el_format_cycle(ct, pat); break;
case EL_CALENDAR_RELATIVE: result = _el_format_cycle(ct, pat); break;
case EL_CALENDAR_NO_CYCLE: {
char buf[64];
snprintf(buf, sizeof(buf), "instant:%lld", (long long)ct->instant_ns);
result = el_strdup(buf);
break;
}
default: result = el_strdup("");
}
return el_wrap_str(result);
}
/* ── LocalDate / LocalTime / LocalDateTime ──────────────────────────────── */
static int _el_days_in_month(int y, int m) {
static const int dim[12] = {31,28,31,30,31,30,31,31,30,31,30,31};
if (m == 2) {
int leap = ((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0);
return 28 + (leap ? 1 : 0);
}
if (m < 1 || m > 12) return 30;
return dim[m - 1];
}
el_val_t local_date(el_val_t y, el_val_t m, el_val_t d) {
el_localdate_t* ld = (el_localdate_t*)malloc(sizeof(el_localdate_t));
ld->magic = EL_LDATE_MAGIC;
ld->year = (int)(int64_t)y;
ld->month = (int)(int64_t)m;
ld->day = (int)(int64_t)d;
return (el_val_t)(uintptr_t)ld;
}
el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns) {
int64_t hh = (int64_t)h;
int64_t mm = (int64_t)m;
int64_t ss = (int64_t)s;
int64_t nn = (int64_t)ns;
int64_t total = hh * 3600000000000LL + mm * 60000000000LL + ss * 1000000000LL + nn;
return (el_val_t)total;
}
el_val_t local_datetime(el_val_t date_val, el_val_t time_val) {
if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdt_t* ldt = (el_localdt_t*)malloc(sizeof(el_localdt_t));
ldt->magic = EL_LDT_MAGIC;
ldt->date = (el_localdate_t*)(uintptr_t)date_val;
ldt->time_ns = (int64_t)time_val;
return (el_val_t)(uintptr_t)ldt;
}
el_val_t zoned(el_val_t date_val, el_val_t time_val, el_val_t cal_val) {
if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* ld = (el_localdate_t*)(uintptr_t)date_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t time_ns = (int64_t)time_val;
/* Convert (LocalDate, LocalTime, EarthCalendar) -> Instant.
* For non-Earth calendars we use day-anchored conversion: treat the
* LocalDate's (y,m,d) as a Gregorian projection, convert to seconds via
* mktime under the calendar's zone, then add nanos-since-midnight. */
if (c->kind == EL_CALENDAR_EARTH) {
_el_apply_zone(c->zone);
struct tm tm; memset(&tm, 0, sizeof(tm));
tm.tm_year = ld->year - 1900;
tm.tm_mon = ld->month - 1;
tm.tm_mday = ld->day;
tm.tm_hour = (int)(time_ns / 3600000000000LL);
tm.tm_min = (int)((time_ns / 60000000000LL) % 60);
tm.tm_sec = (int)((time_ns / 1000000000LL) % 60);
tm.tm_isdst = -1;
time_t t = mktime(&tm);
if (t == (time_t)-1) return (el_val_t)0;
int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
/* Non-Earth fallback: project as if Earth UTC then attach calendar. */
struct tm tm; memset(&tm, 0, sizeof(tm));
tm.tm_year = ld->year - 1900;
tm.tm_mon = ld->month - 1;
tm.tm_mday = ld->day;
tm.tm_hour = (int)(time_ns / 3600000000000LL);
tm.tm_min = (int)((time_ns / 60000000000LL) % 60);
tm.tm_sec = (int)((time_ns / 1000000000LL) % 60);
time_t t = timegm(&tm);
if (t == (time_t)-1) return (el_val_t)0;
int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}
el_val_t local_date_year(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->year;
}
el_val_t local_date_month(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->month;
}
el_val_t local_date_day(el_val_t v) {
if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
return (el_val_t)((el_localdate_t*)(uintptr_t)v)->day;
}
el_val_t local_time_hour(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)(t / 3600000000000LL);
}
el_val_t local_time_minute(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)((t / 60000000000LL) % 60);
}
el_val_t local_time_second(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)((t / 1000000000LL) % 60);
}
el_val_t local_time_nanos(el_val_t v) {
int64_t t = (int64_t)v;
return (el_val_t)(t % 1000000000LL);
}
el_val_t el_local_date_add_dur(el_val_t ld_val, el_val_t dur_val) {
if (!el_is_magic(ld_val, EL_LDATE_MAGIC)) return ld_val;
el_localdate_t* ld = (el_localdate_t*)(uintptr_t)ld_val;
int64_t dur_ns = (int64_t)dur_val;
int64_t days = dur_ns / EL_EARTH_DAY_NS;
int y = ld->year, m = ld->month, d = ld->day;
/* Walk days forward/backward in canonical Gregorian. */
while (days > 0) {
int dim = _el_days_in_month(y, m);
if (d + days <= dim) { d += (int)days; days = 0; break; }
days -= (dim - d + 1);
d = 1;
m++;
if (m > 12) { m = 1; y++; }
}
while (days < 0) {
if (d + days >= 1) { d += (int)days; days = 0; break; }
days += d;
m--;
if (m < 1) { m = 12; y--; }
d = _el_days_in_month(y, m);
}
return local_date((el_val_t)y, (el_val_t)m, (el_val_t)d);
}
el_val_t el_local_time_add_dur(el_val_t lt_val, el_val_t dur_val) {
int64_t t = (int64_t)lt_val + (int64_t)dur_val;
/* Wrap mod 24h on Earth-default. CycleCalendar wrapping requires the
* caller to use cal_in / cal_format for the right modulus. */
int64_t day = EL_EARTH_DAY_NS;
int64_t r = t % day;
if (r < 0) r += day;
return (el_val_t)r;
}
el_val_t el_local_date_lt(el_val_t a_val, el_val_t b_val) {
if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
if (a->year != b->year) return (el_val_t)(a->year < b->year ? 1 : 0);
if (a->month != b->month) return (el_val_t)(a->month < b->month ? 1 : 0);
return (el_val_t)(a->day < b->day ? 1 : 0);
}
el_val_t el_local_date_eq(el_val_t a_val, el_val_t b_val) {
if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
return (el_val_t)((a->year == b->year && a->month == b->month && a->day == b->day) ? 1 : 0);
}
/* ── Rhythm ──────────────────────────────────────────────────────────────── */
static el_rhythm_t* _el_rhythm_alloc(el_rhythm_kind_t k) {
el_rhythm_t* r = (el_rhythm_t*)calloc(1, sizeof(el_rhythm_t));
r->magic = EL_RHYTHM_MAGIC;
r->kind = k;
return r;
}
el_val_t rhythm_cycle_start(void) {
return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_CYCLE_START);
}
el_val_t rhythm_cycle_phase(el_val_t phase_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_CYCLE_PHASE);
r->phase = el_to_float(phase_val);
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_duration(el_val_t d_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_DURATION);
r->period_ns = (int64_t)d_val;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_session_start(void) {
return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_SESSION_START);
}
el_val_t rhythm_event(el_val_t name_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_EVENT);
const char* n = EL_CSTR(name_val);
r->event_name = el_strdup_persist(n ? n : "");
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_and(el_val_t a_val, el_val_t b_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_AND);
r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_or(el_val_t a_val, el_val_t b_val) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_OR);
r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_weekday(el_val_t day) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKDAY);
r->weekday = (int)(int64_t)day;
return (el_val_t)(uintptr_t)r;
}
el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute) {
el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKLY_AT);
r->weekday = (int)(int64_t)day;
r->hour = (int)(int64_t)hour;
r->minute = (int)(int64_t)minute;
return (el_val_t)(uintptr_t)r;
}
/* Compute the next instant on or after `after` when rhythm `r` matches,
* under calendar `cal`. */
static int64_t _el_next_after(el_rhythm_t* r, int64_t after_ns, el_calendar_t* cal) {
if (!r) return after_ns;
int64_t period = cal->cycle_period_ns > 0 ? cal->cycle_period_ns : EL_EARTH_DAY_NS;
switch (r->kind) {
case EL_RHYTHM_CYCLE_START: {
int64_t base = after_ns - cal->epoch_ns;
int64_t cyc = (base / period) + 1;
return cal->epoch_ns + cyc * period;
}
case EL_RHYTHM_CYCLE_PHASE: {
int64_t base = after_ns - cal->epoch_ns;
int64_t cyc_ns = (int64_t)(r->phase * (double)period);
int64_t cur_cyc = base / period;
int64_t candidate = cal->epoch_ns + cur_cyc * period + cyc_ns;
if (candidate <= after_ns) candidate += period;
return candidate;
}
case EL_RHYTHM_DURATION: {
return after_ns + (r->period_ns > 0 ? r->period_ns : 1);
}
case EL_RHYTHM_WEEKDAY:
case EL_RHYTHM_WEEKLY_AT: {
if (cal->kind != EL_CALENDAR_EARTH) {
/* Non-Earth calendars: fall back to cycle math, treating
* weekday as a 7-cycle-per-period proxy. */
return after_ns + period;
}
_el_apply_zone(cal->zone);
time_t s = (time_t)(after_ns / 1000000000LL);
struct tm tm;
localtime_r(&s, &tm);
/* tm_wday: 0=Sun..6=Sat. We use 1=Mon..7=Sun. */
int target = r->weekday >= 1 && r->weekday <= 7 ? r->weekday : 1;
int target_wday = target == 7 ? 0 : target; /* 7→Sun=0, 1→Mon=1 */
int days_ahead = (target_wday - tm.tm_wday + 7) % 7;
int hour = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->hour : 0;
int minute = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->minute : 0;
struct tm cand = tm;
cand.tm_mday += days_ahead;
cand.tm_hour = hour;
cand.tm_min = minute;
cand.tm_sec = 0;
cand.tm_isdst = -1;
time_t cand_t = mktime(&cand);
int64_t cand_ns = (int64_t)cand_t * 1000000000LL;
if (cand_ns <= after_ns) {
cand.tm_mday += 7;
cand.tm_isdst = -1;
cand_t = mktime(&cand);
cand_ns = (int64_t)cand_t * 1000000000LL;
}
return cand_ns;
}
case EL_RHYTHM_AND: {
int64_t a = _el_next_after(r->a, after_ns, cal);
int64_t b = _el_next_after(r->b, after_ns, cal);
return a > b ? a : b;
}
case EL_RHYTHM_OR: {
int64_t a = _el_next_after(r->a, after_ns, cal);
int64_t b = _el_next_after(r->b, after_ns, cal);
return a < b ? a : b;
}
case EL_RHYTHM_SESSION_START:
case EL_RHYTHM_EVENT:
default:
return after_ns;
}
}
el_val_t rhythm_next_after(el_val_t r_val, el_val_t after_val, el_val_t cal_val) {
if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return after_val;
el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
el_calendar_t* c = _el_resolve_cal(cal_val);
int64_t out = _el_next_after(r, (int64_t)after_val, c);
return (el_val_t)out;
}
el_val_t rhythm_matches(el_val_t r_val, el_val_t ct_val) {
if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return (el_val_t)0;
if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
int64_t period = ct->cal->cycle_period_ns > 0 ? ct->cal->cycle_period_ns : EL_EARTH_DAY_NS;
int64_t base = ct->instant_ns - ct->cal->epoch_ns;
int64_t phase_ns = base % period;
if (phase_ns < 0) phase_ns += period;
double phase = (double)phase_ns / (double)period;
switch (r->kind) {
case EL_RHYTHM_CYCLE_START: return (el_val_t)(phase_ns == 0 ? 1 : 0);
case EL_RHYTHM_CYCLE_PHASE: {
double diff = phase - r->phase;
if (diff < 0) diff = -diff;
return (el_val_t)(diff < 0.001 ? 1 : 0);
}
default: return (el_val_t)0;
}
}
/* ── UUID v4 ─────────────────────────────────────────────────────────────── */
static int _el_uuid_seeded = 0;
static void _el_uuid_seed(void) {
if (!_el_uuid_seeded) {
srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&_el_uuid_seeded);
_el_uuid_seeded = 1;
}
}
el_val_t uuid_new(void) {
_el_uuid_seed();
unsigned char b[16];
for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xff);
/* Version 4 */
b[6] = (b[6] & 0x0f) | 0x40;
/* RFC 4122 variant */
b[8] = (b[8] & 0x3f) | 0x80;
char buf[37];
snprintf(buf, sizeof(buf),
"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0], b[1], b[2], b[3],
b[4], b[5],
b[6], b[7],
b[8], b[9],
b[10], b[11], b[12], b[13], b[14], b[15]);
return el_wrap_str(el_strdup(buf));
}
el_val_t uuid_v4(void) { return uuid_new(); }
/* ── Environment ─────────────────────────────────────────────────────────── */
el_val_t env(el_val_t key) {
const char* k = EL_CSTR(key);
if (!k) return el_wrap_str(el_strdup(""));
const char* v = getenv(k);
return el_wrap_str(el_strdup(v ? v : ""));
}
/* ── In-process state K/V ────────────────────────────────────────────────── */
typedef struct {
char* key;
char* value;
} StateEntry;
static StateEntry* _state_entries = NULL;
static size_t _state_count = 0;
static size_t _state_cap = 0;
/* Mutex protecting all _state_entries access. state_set/state_get are called
* concurrently from 64 HTTP worker threads — without this lock, realloc and
* free race, producing corruption, double-free, and segfaults. */
static pthread_mutex_t _state_mu = PTHREAD_MUTEX_INITIALIZER;
static StateEntry* state_find(const char* key) {
for (size_t i = 0; i < _state_count; i++) {
if (strcmp(_state_entries[i].key, key) == 0) return &_state_entries[i];
}
return NULL;
}
el_val_t state_set(el_val_t key, el_val_t value) {
const char* k = EL_CSTR(key);
const char* v = EL_CSTR(value);
if (!k) return 0;
if (!v) v = "";
pthread_mutex_lock(&_state_mu);
StateEntry* e = state_find(k);
if (e) {
free(e->value);
e->value = el_strdup_persist(v);
pthread_mutex_unlock(&_state_mu);
return 1;
}
if (_state_count >= _state_cap) {
size_t nc = _state_cap == 0 ? 16 : _state_cap * 2;
StateEntry* grown = realloc(_state_entries, nc * sizeof(StateEntry));
if (!grown) { pthread_mutex_unlock(&_state_mu); fputs("el_runtime: out of memory\n", stderr); exit(1); }
_state_entries = grown;
_state_cap = nc;
}
_state_entries[_state_count].key = el_strdup_persist(k);
_state_entries[_state_count].value = el_strdup_persist(v);
_state_count++;
pthread_mutex_unlock(&_state_mu);
return 1;
}
el_val_t state_get(el_val_t key) {
const char* k = EL_CSTR(key);
if (!k) return el_wrap_str(el_strdup(""));
pthread_mutex_lock(&_state_mu);
StateEntry* e = state_find(k);
char* result = el_strdup_persist(e ? e->value : "");
pthread_mutex_unlock(&_state_mu);
/* wrap in arena-tracked copy for the caller's request lifetime */
char* copy = el_strdup(result);
return el_wrap_str(copy);
}
el_val_t state_del(el_val_t key) {
const char* k = EL_CSTR(key);
if (!k) return 0;
pthread_mutex_lock(&_state_mu);
for (size_t i = 0; i < _state_count; i++) {
if (strcmp(_state_entries[i].key, k) == 0) {
free(_state_entries[i].key);
free(_state_entries[i].value);
for (size_t j = i + 1; j < _state_count; j++) {
_state_entries[j - 1] = _state_entries[j];
}
_state_count--;
pthread_mutex_unlock(&_state_mu);
return 1;
}
}
pthread_mutex_unlock(&_state_mu);
return 1;
}
el_val_t state_keys(void) {
pthread_mutex_lock(&_state_mu);
/* Build a JSON array string: ["key1","key2",...] */
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
for (size_t i = 0; i < _state_count; i++) {
if (i > 0) jb_putc(&b, ',');
jb_putc(&b, '"');
jb_emit_escaped(&b, _state_entries[i].key);
jb_putc(&b, '"');
}
jb_putc(&b, ']');
pthread_mutex_unlock(&_state_mu);
return el_wrap_str(b.buf);
}
/* Returns 1 (true) if the key is present in the state store, else 0 (false). */
el_val_t state_has(el_val_t key) {
const char* k = EL_CSTR(key);
if (!k) return 0;
pthread_mutex_lock(&_state_mu);
StateEntry* e = state_find(k);
int found = (e != NULL) ? 1 : 0;
pthread_mutex_unlock(&_state_mu);
return (el_val_t)found;
}
/* Returns the value for key, or default_val if the key is absent. */
el_val_t state_get_or(el_val_t key, el_val_t default_val) {
const char* k = EL_CSTR(key);
if (!k) return default_val;
pthread_mutex_lock(&_state_mu);
StateEntry* e = state_find(k);
if (e) {
char* copy = el_strdup(e->value);
pthread_mutex_unlock(&_state_mu);
return el_wrap_str(copy);
}
pthread_mutex_unlock(&_state_mu);
return default_val;
}
/* ── Float formatting ────────────────────────────────────────────────────── */
el_val_t float_to_str(el_val_t f) {
char buf[64];
double v = el_to_float(f);
/* Normalize NaN to "nan" regardless of sign — platform-independent. */
if (isnan(v)) {
snprintf(buf, sizeof(buf), "nan");
} else {
snprintf(buf, sizeof(buf), "%g", v);
}
return el_wrap_str(el_strdup(buf));
}
el_val_t int_to_float(el_val_t n) {
return el_from_float((double)(int64_t)n);
}
el_val_t float_to_int(el_val_t f) {
return (el_val_t)(int64_t)el_to_float(f);
}
el_val_t format_float(el_val_t f, el_val_t decimals) {
int d = (int)(int64_t)decimals;
if (d < 0) d = 0;
if (d > 30) d = 30;
char buf[128];
snprintf(buf, sizeof(buf), "%.*f", d, el_to_float(f));
return el_wrap_str(el_strdup(buf));
}
el_val_t decimal_round(el_val_t f, el_val_t decimals) {
int d = (int)(int64_t)decimals;
if (d < 0) d = 0;
if (d > 15) d = 15;
double mul = pow(10.0, (double)d);
double v = el_to_float(f);
double r = (v >= 0.0 ? floor(v * mul + 0.5) : -floor(-v * mul + 0.5)) / mul;
return el_from_float(r);
}
el_val_t str_to_float(el_val_t s) {
const char* str = EL_CSTR(s);
if (!str) return el_from_float(0.0);
return el_from_float(strtod(str, NULL));
}
/* ── Math (Float-aware) ──────────────────────────────────────────────────── */
el_val_t math_sqrt(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
el_val_t math_log(el_val_t f) { return el_from_float(log(el_to_float(f))); }
el_val_t math_ln(el_val_t f) { return el_from_float(log(el_to_float(f))); }
el_val_t math_sin(el_val_t f) { return el_from_float(sin(el_to_float(f))); }
el_val_t math_cos(el_val_t f) { return el_from_float(cos(el_to_float(f))); }
el_val_t math_pi(void) { return el_from_float(3.141592653589793238462643383279502884); }
/* ── String additions ────────────────────────────────────────────────────── */
el_val_t str_index_of(el_val_t s, el_val_t sub) {
const char* str = EL_CSTR(s);
const char* sb = EL_CSTR(sub);
if (!str || !sb) return -1;
const char* hit = strstr(str, sb);
if (!hit) return -1;
return (el_val_t)(int64_t)(hit - str);
}
el_val_t str_split(el_val_t s, el_val_t sep) {
const char* str = EL_CSTR(s);
const char* sp = EL_CSTR(sep);
el_val_t lst = el_list_empty();
if (!str) return lst;
if (!sp || !*sp) {
lst = el_list_append(lst, el_wrap_str(el_strdup(str)));
return lst;
}
size_t lp = strlen(sp);
const char* p = str;
const char* hit;
while ((hit = strstr(p, sp)) != NULL) {
size_t n = (size_t)(hit - p);
char* out = el_strbuf(n);
memcpy(out, p, n);
out[n] = '\0';
lst = el_list_append(lst, el_wrap_str(out));
p = hit + lp;
}
lst = el_list_append(lst, el_wrap_str(el_strdup(p)));
return lst;
}
el_val_t str_char_at(el_val_t s, el_val_t i) {
const char* str = EL_CSTR(s);
int64_t idx = (int64_t)i;
if (!str) return el_wrap_str(el_strdup(""));
int64_t n = (int64_t)strlen(str);
if (idx < 0 || idx >= n) return el_wrap_str(el_strdup(""));
char buf[2];
buf[0] = str[idx];
buf[1] = '\0';
return el_wrap_str(el_strdup(buf));
}
el_val_t str_char_code(el_val_t s, el_val_t i) {
const char* str = EL_CSTR(s);
int64_t idx = (int64_t)i;
if (!str) return 0;
int64_t n = (int64_t)strlen(str);
if (idx < 0 || idx >= n) return 0;
return (el_val_t)(unsigned char)str[idx];
}
static el_val_t str_pad(const char* s, int64_t width, const char* pad, int left) {
if (!s) s = "";
if (!pad || !*pad) pad = " ";
int64_t lp = (int64_t)strlen(pad);
int64_t ls = (int64_t)strlen(s);
if (ls >= width) return el_wrap_str(el_strdup(s));
int64_t need = width - ls;
char* out = el_strbuf((size_t)width);
if (left) {
for (int64_t i = 0; i < need; i++) out[i] = pad[i % lp];
memcpy(out + need, s, (size_t)ls);
} else {
memcpy(out, s, (size_t)ls);
for (int64_t i = 0; i < need; i++) out[ls + i] = pad[i % lp];
}
out[width] = '\0';
return el_wrap_str(out);
}
el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad) {
return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 1);
}
el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad) {
return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 0);
}
el_val_t str_format(el_val_t fmt, el_val_t data) {
const char* tpl = EL_CSTR(fmt);
if (!tpl) return el_wrap_str(el_strdup(""));
JsonBuf b; jb_init(&b);
const char* p = tpl;
while (*p) {
if (*p == '{') {
const char* q = p + 1;
while (*q && *q != '}') q++;
if (*q == '}') {
size_t klen = (size_t)(q - p - 1);
char keybuf[256];
if (klen < sizeof(keybuf)) {
memcpy(keybuf, p + 1, klen);
keybuf[klen] = '\0';
el_val_t v = el_map_get(data, EL_STR(keybuf));
if (v != 0 && looks_like_string(v)) {
jb_puts(&b, EL_CSTR(v));
p = q + 1;
continue;
} else if (v != 0) {
jb_emit_int(&b, (int64_t)v);
p = q + 1;
continue;
}
}
/* Unknown key — leave {key} verbatim */
jb_reserve(&b, klen + 2);
memcpy(b.buf + b.len, p, klen + 2);
b.len += klen + 2;
b.buf[b.len] = '\0';
p = q + 1;
continue;
}
}
jb_putc(&b, *p);
p++;
}
return el_wrap_str(b.buf);
}
el_val_t str_lower(el_val_t s) { return str_to_lower(s); }
el_val_t str_upper(el_val_t s) { return str_to_upper(s); }
/* ── Text-processing primitives (Phase 1: byte/codepoint, ASCII char classes)
*
* Phase 1 covers the operations every text-handling caller used to roll by
* hand on top of str_index_of + str_slice. The character-class predicates
* (is_letter / is_digit / ...) are ASCII only — Unicode-grapheme awareness,
* NFC/NFD normalization, and regex are Phase 2. Single-char input checks the
* first byte; multi-char input requires ALL bytes to match (false otherwise).
*
* Counting:
* str_count non-overlapping occurrences of sub in s
* str_count_chars codepoint count (UTF-8 leading-byte count)
* str_count_bytes explicit byte length (alias of str_len)
* str_count_lines \n-delimited line count (\r\n folded to \n)
* str_count_words whitespace-delimited tokens, non-empty only
* str_count_letters ASCII [A-Za-z]
* str_count_digits ASCII [0-9]
*
* Find / position:
* str_index_of_all all byte offsets of sub, [] if none
* str_last_index_of last byte offset of sub, -1 if not found
* str_find_chars first index of any char in any_of, -1 if none
*
* Transform:
* str_repeat s * n (non-negative)
* str_reverse codepoint-reversed (NOT grapheme-aware)
* str_strip_prefix s without prefix if present, else s
* str_strip_suffix s without suffix if present, else s
* str_strip_chars strip leading+trailing chars matching any in chars
* str_lstrip strip leading whitespace
* str_rstrip strip trailing whitespace
*
* Char classification (Bool):
* is_letter, is_digit, is_alphanumeric, is_whitespace,
* is_punctuation, is_uppercase, is_lowercase
*
* Splitting:
* str_split_lines \n-delimited (\r\n folded). Trailing empty dropped.
* str_split_chars alias of native_string_chars in str_ namespace
* str_split_n split into at most n parts (last part keeps the
* rest verbatim, including any further separators)
*
* Joining:
* str_join [String] -> String, sep between elements
*/
/* Count non-overlapping occurrences of sub in s. Empty sub returns 0. */
el_val_t str_count(el_val_t sv, el_val_t subv) {
const char* s = EL_CSTR(sv);
const char* sub = EL_CSTR(subv);
if (!s || !sub || !*sub) return 0;
size_t lp = strlen(sub);
int64_t count = 0;
const char* p = s;
while ((p = strstr(p, sub)) != NULL) {
count++;
p += lp; /* non-overlapping advance */
}
return (el_val_t)count;
}
/* Codepoint count: walk bytes, count those NOT matching 10xxxxxx. */
el_val_t str_count_chars(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
int64_t count = 0;
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if ((*p & 0xC0) != 0x80) count++;
}
return (el_val_t)count;
}
el_val_t str_count_bytes(el_val_t sv) {
return str_len(sv);
}
el_val_t str_count_lines(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s || !*s) return 0;
int64_t count = 0;
int has_content = 0;
for (const char* p = s; *p; p++) {
has_content = 1;
if (*p == '\n') {
count++;
has_content = 0; /* the \n closed the line */
}
}
if (has_content) count++; /* trailing line with no terminator */
return (el_val_t)count;
}
el_val_t str_count_words(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
int64_t count = 0;
int in_word = 0;
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if (isspace(*p)) {
in_word = 0;
} else if (!in_word) {
in_word = 1;
count++;
}
}
return (el_val_t)count;
}
el_val_t str_count_letters(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
int64_t count = 0;
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if ((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z')) count++;
}
return (el_val_t)count;
}
el_val_t str_count_digits(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return 0;
int64_t count = 0;
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if (*p >= '0' && *p <= '9') count++;
}
return (el_val_t)count;
}
el_val_t str_index_of_all(el_val_t sv, el_val_t subv) {
const char* s = EL_CSTR(sv);
const char* sub = EL_CSTR(subv);
el_val_t lst = el_list_empty();
if (!s || !sub || !*sub) return lst;
size_t lp = strlen(sub);
const char* p = s;
const char* hit;
while ((hit = strstr(p, sub)) != NULL) {
lst = el_list_append(lst, (el_val_t)(int64_t)(hit - s));
p = hit + lp;
}
return lst;
}
el_val_t str_last_index_of(el_val_t sv, el_val_t subv) {
const char* s = EL_CSTR(sv);
const char* sub = EL_CSTR(subv);
if (!s || !sub || !*sub) return -1;
size_t lp = strlen(sub);
int64_t last = -1;
const char* p = s;
const char* hit;
while ((hit = strstr(p, sub)) != NULL) {
last = (int64_t)(hit - s);
p = hit + lp;
}
return (el_val_t)last;
}
el_val_t str_find_chars(el_val_t sv, el_val_t any_of_v) {
const char* s = EL_CSTR(sv);
const char* any = EL_CSTR(any_of_v);
if (!s || !any || !*any) return -1;
for (const char* p = s; *p; p++) {
if (strchr(any, *p)) return (el_val_t)(int64_t)(p - s);
}
return -1;
}
el_val_t str_repeat(el_val_t sv, el_val_t nv) {
const char* s = EL_CSTR(sv);
int64_t n = (int64_t)nv;
if (!s || n <= 0) return el_wrap_str(el_strdup(""));
size_t ls = strlen(s);
if (ls == 0) return el_wrap_str(el_strdup(""));
size_t total = ls * (size_t)n;
char* out = el_strbuf(total);
for (int64_t i = 0; i < n; i++) {
memcpy(out + i * ls, s, ls);
}
out[total] = '\0';
return el_wrap_str(out);
}
/* Reverse by codepoint: walk codepoints, copy each backwards into the output.
* NOT grapheme-aware (Phase 2). Combining marks attached to a base codepoint
* will detach. ASCII strings are byte-reverse equivalent. */
el_val_t str_reverse(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
char* out = el_strbuf(n);
/* Walk forward, find each codepoint's byte length, then copy from the end. */
size_t out_pos = n;
const unsigned char* p = (const unsigned char*)s;
while (*p) {
int cp_len;
if ((*p & 0x80) == 0x00) cp_len = 1;
else if ((*p & 0xE0) == 0xC0) cp_len = 2;
else if ((*p & 0xF0) == 0xE0) cp_len = 3;
else if ((*p & 0xF8) == 0xF0) cp_len = 4;
else cp_len = 1; /* invalid byte: passthrough */
out_pos -= cp_len;
memcpy(out + out_pos, p, cp_len);
p += cp_len;
}
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t str_strip_prefix(el_val_t sv, el_val_t prefv) {
const char* s = EL_CSTR(sv);
const char* pref = EL_CSTR(prefv);
if (!s) return el_wrap_str(el_strdup(""));
if (!pref || !*pref) return el_wrap_str(el_strdup(s));
size_t lp = strlen(pref);
size_t ls = strlen(s);
if (lp <= ls && strncmp(s, pref, lp) == 0) {
char* out = el_strbuf(ls - lp);
memcpy(out, s + lp, ls - lp);
out[ls - lp] = '\0';
return el_wrap_str(out);
}
return el_wrap_str(el_strdup(s));
}
el_val_t str_strip_suffix(el_val_t sv, el_val_t sufv) {
const char* s = EL_CSTR(sv);
const char* suf = EL_CSTR(sufv);
if (!s) return el_wrap_str(el_strdup(""));
if (!suf || !*suf) return el_wrap_str(el_strdup(s));
size_t ls = strlen(s);
size_t lsuf = strlen(suf);
if (lsuf <= ls && strcmp(s + ls - lsuf, suf) == 0) {
char* out = el_strbuf(ls - lsuf);
memcpy(out, s, ls - lsuf);
out[ls - lsuf] = '\0';
return el_wrap_str(out);
}
return el_wrap_str(el_strdup(s));
}
el_val_t str_strip_chars(el_val_t sv, el_val_t charsv) {
const char* s = EL_CSTR(sv);
const char* chars = EL_CSTR(charsv);
if (!s) return el_wrap_str(el_strdup(""));
if (!chars || !*chars) return el_wrap_str(el_strdup(s));
const char* start = s;
while (*start && strchr(chars, *start)) start++;
size_t n = strlen(start);
while (n > 0 && strchr(chars, start[n - 1])) n--;
char* out = el_strbuf(n);
memcpy(out, start, n);
out[n] = '\0';
return el_wrap_str(out);
}
el_val_t str_lstrip(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
while (*s && isspace((unsigned char)*s)) s++;
return el_wrap_str(el_strdup(s));
}
el_val_t str_rstrip(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) return el_wrap_str(el_strdup(""));
size_t n = strlen(s);
while (n > 0 && isspace((unsigned char)s[n - 1])) n--;
char* out = el_strbuf(n);
memcpy(out, s, n);
out[n] = '\0';
return el_wrap_str(out);
}
/* Character classification.
* Empty input returns false. Multi-char input requires ALL bytes to match.
* ASCII range only; Phase 2 will widen to Unicode. */
static int s_all_match(el_val_t sv, int (*pred)(unsigned char)) {
const char* s = EL_CSTR(sv);
if (!s || !*s) return 0;
for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
if (!pred(*p)) return 0;
}
return 1;
}
static int p_letter(unsigned char c) { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); }
static int p_digit(unsigned char c) { return c >= '0' && c <= '9'; }
static int p_alnum(unsigned char c) { return p_letter(c) || p_digit(c); }
static int p_white(unsigned char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; }
static int p_punct(unsigned char c) { return ispunct(c) ? 1 : 0; }
static int p_upper(unsigned char c) { return c >= 'A' && c <= 'Z'; }
static int p_lower(unsigned char c) { return c >= 'a' && c <= 'z'; }
el_val_t is_letter(el_val_t s) { return (el_val_t)s_all_match(s, p_letter); }
el_val_t is_digit(el_val_t s) { return (el_val_t)s_all_match(s, p_digit); }
el_val_t is_alphanumeric(el_val_t s) { return (el_val_t)s_all_match(s, p_alnum); }
el_val_t is_whitespace(el_val_t s) { return (el_val_t)s_all_match(s, p_white); }
el_val_t is_punctuation(el_val_t s) { return (el_val_t)s_all_match(s, p_punct); }
el_val_t is_uppercase(el_val_t s) { return (el_val_t)s_all_match(s, p_upper); }
el_val_t is_lowercase(el_val_t s) { return (el_val_t)s_all_match(s, p_lower); }
/* Split on \n. \r\n is folded to \n first. Trailing empty after final \n
* is dropped (so "a\nb\n" -> ["a", "b"], not ["a", "b", ""]). */
el_val_t str_split_lines(el_val_t sv) {
const char* s = EL_CSTR(sv);
el_val_t lst = el_list_empty();
if (!s) return lst;
size_t n = strlen(s);
/* Pre-scan: build into a normalized buffer with \r\n folded. */
const char* line_start = s;
for (size_t i = 0; i <= n; i++) {
if (s[i] == '\n' || s[i] == '\0') {
size_t len = (size_t)(s + i - line_start);
/* Drop trailing \r if this was \r\n. */
if (len > 0 && line_start[len - 1] == '\r') len--;
/* Drop final trailing-empty-after-newline. */
if (s[i] == '\0' && len == 0 && i > 0 && s[i - 1] == '\n') break;
char* out = el_strbuf(len);
memcpy(out, line_start, len);
out[len] = '\0';
lst = el_list_append(lst, el_wrap_str(out));
if (s[i] == '\0') break;
line_start = s + i + 1;
}
}
return lst;
}
el_val_t str_split_chars(el_val_t s) {
return native_string_chars(s);
}
/* Split into at most n parts. The (n-1)th split point is the LAST split;
* after it, the remainder is appended verbatim including any further
* separators. n <= 0 returns an empty list. n == 1 returns [s]. */
el_val_t str_split_n(el_val_t sv, el_val_t sepv, el_val_t nv) {
const char* s = EL_CSTR(sv);
const char* sep = EL_CSTR(sepv);
int64_t n = (int64_t)nv;
el_val_t lst = el_list_empty();
if (!s) return lst;
if (n <= 0) return lst;
if (n == 1 || !sep || !*sep) {
lst = el_list_append(lst, el_wrap_str(el_strdup(s)));
return lst;
}
size_t lp = strlen(sep);
const char* p = s;
int64_t parts = 0;
const char* hit;
while (parts < n - 1 && (hit = strstr(p, sep)) != NULL) {
size_t len = (size_t)(hit - p);
char* out = el_strbuf(len);
memcpy(out, p, len);
out[len] = '\0';
lst = el_list_append(lst, el_wrap_str(out));
p = hit + lp;
parts++;
}
/* Remainder verbatim. */
lst = el_list_append(lst, el_wrap_str(el_strdup(p)));
return lst;
}
/* Join a [String] with a separator. Empty list -> "". Single-element ->
* that element. Non-string elements are stringified via int_to_str. */
el_val_t str_join(el_val_t listv, el_val_t sepv) {
return list_join(listv, sepv);
}
/* ── List additions ──────────────────────────────────────────────────────── */
el_val_t list_push(el_val_t list, el_val_t elem) {
return el_list_append(list, elem);
}
el_val_t list_push_front(el_val_t listv, el_val_t elem) {
ElList* lst = (ElList*)(uintptr_t)listv;
if (!lst) {
el_val_t nl = el_list_empty();
return el_list_append(nl, elem);
}
/* Append to grow capacity, then shift right */
listv = el_list_append(listv, elem);
lst = (ElList*)(uintptr_t)listv;
for (int64_t i = lst->length - 1; i > 0; i--) {
lst->elems[i] = lst->elems[i - 1];
}
lst->elems[0] = elem;
return EL_STR(lst);
}
el_val_t list_join(el_val_t listv, el_val_t sep) {
ElList* lst = (ElList*)(uintptr_t)listv;
const char* sp = EL_CSTR(sep);
if (!sp) sp = "";
if (!lst || lst->length == 0) return el_wrap_str(el_strdup(""));
JsonBuf b; jb_init(&b);
for (int64_t i = 0; i < lst->length; i++) {
if (i > 0) jb_puts(&b, sp);
el_val_t v = lst->elems[i];
if (v == 0) continue;
if (looks_like_string(v)) {
jb_puts(&b, EL_CSTR(v));
} else {
char tmp[32];
snprintf(tmp, sizeof(tmp), "%lld", (long long)v);
jb_puts(&b, tmp);
}
}
return el_wrap_str(b.buf);
}
el_val_t list_range(el_val_t start, el_val_t end) {
int64_t a = (int64_t)start;
int64_t b = (int64_t)end;
el_val_t lst = el_list_empty();
for (int64_t i = a; i < b; i++) lst = el_list_append(lst, (el_val_t)i);
return lst;
}
/* ── Bool helpers ────────────────────────────────────────────────────────── */
el_val_t bool_to_str(el_val_t b) {
return el_wrap_str(el_strdup(b ? "true" : "false"));
}
/* ── Numeric parsing ─────────────────────────────────────────────────────── */
/* parse_int — strtoll with a default. str_to_int already exists but does not
* distinguish "0" from a parse failure, so callers that need a sentinel use
* this. Skips leading whitespace; accepts an optional leading +/-; returns
* default_val on empty input or no consumed digits. Trailing junk is ignored
* (atoi-style). */
el_val_t parse_int(el_val_t sv, el_val_t default_val) {
const char* s = EL_CSTR(sv);
if (!s) return default_val;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == '\0') return default_val;
char* end = NULL;
long long n = strtoll(s, &end, 10);
if (end == s) return default_val;
return (el_val_t)n;
}
/* ── Process ─────────────────────────────────────────────────────────────── */
el_val_t exit_program(el_val_t code) {
exit((int)code);
return 0; /* unreachable */
}
/* getpid_now — current process id. Named with the _now suffix to avoid
* colliding with the libc `getpid` declaration that the runtime already
* sees via <unistd.h> (calling it `getpid` would fight the prototype). */
el_val_t getpid_now(void) {
return (el_val_t)getpid();
}
/* el_mem_check — self-terminating memory guard for long-running compiler runs.
*
* Call this periodically (e.g. after each function compiled) to detect runaway
* memory growth before the OS OOM-killer fires. Reads the limit from the env
* var ELC_MAX_MEM_MB (default 512 MB). If resident set size exceeds the limit,
* prints a diagnostic to stderr and exits with code 1 so the caller (elb or a
* CI script) can handle the failure gracefully instead of having the whole
* machine go down.
*
* Platform notes:
* macOS — ru_maxrss is in bytes.
* Linux — ru_maxrss is in kilobytes.
* We normalise to MB before comparing.
*
* Returns 0 always (the only non-return path is the exit() branch).
*/
el_val_t el_mem_check(void) {
/* Read limit from env; default 512 MB. */
long limit_mb = 512;
const char *env_val = getenv("ELC_MAX_MEM_MB");
if (env_val && *env_val) {
long v = atol(env_val);
if (v > 0) limit_mb = v;
}
struct rusage ru;
if (getrusage(RUSAGE_SELF, &ru) != 0) return 0; /* can't read — skip check */
long rss_mb;
#if defined(__APPLE__) || defined(__MACH__)
/* macOS: ru_maxrss is bytes */
rss_mb = (long)(ru.ru_maxrss / (1024L * 1024L));
#else
/* Linux: ru_maxrss is kilobytes */
rss_mb = (long)(ru.ru_maxrss / 1024L);
#endif
if (rss_mb >= limit_mb) {
fprintf(stderr, "elc: memory limit exceeded (%ldMB), aborting\n", limit_mb);
exit(1);
}
return 0;
}
/* ── args() — command-line argument access ──────────────────────────────────
* Compiled El programs call args() to get a list of CLI arguments.
* Call el_runtime_init_args(argc, argv) at the start of C main() to populate.
* The args list excludes argv[0] (the program name). */
static el_val_t _el_args_list = 0;
void el_runtime_init_args(int argc, char** argv) {
_el_args_list = el_list_empty();
for (int i = 1; i < argc; i++) {
_el_args_list = el_list_append(_el_args_list, EL_STR(argv[i]));
}
}
el_val_t args(void) {
if (!_el_args_list) _el_args_list = el_list_empty();
return _el_args_list;
}
/* ── CGI identity ────────────────────────────────────────────────────────────
* Called once at program start by the generated main() of a cgi {} program.
* Stores CGI identity so dharma_* builtins can reference it. */
static const char* _el_cgi_name = NULL;
static const char* _el_cgi_dharma_id = NULL;
static const char* _el_cgi_principal = NULL;
static const char* _el_cgi_network = NULL;
static const char* _el_cgi_engram = NULL;
void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal,
el_val_t network, el_val_t engram) {
_el_cgi_name = EL_CSTR(name);
_el_cgi_dharma_id = EL_CSTR(dharma_id);
_el_cgi_principal = EL_CSTR(principal);
_el_cgi_network = EL_CSTR(network) ? EL_CSTR(network) : "dharma-mainnet";
_el_cgi_engram = EL_CSTR(engram) ? EL_CSTR(engram) : "http://localhost:8742";
printf("[cgi] identity: name=%s dharma_id=%s principal=%s network=%s engram=%s\n",
_el_cgi_name ? _el_cgi_name : "(unset)",
_el_cgi_dharma_id ? _el_cgi_dharma_id : "(unset)",
_el_cgi_principal ? _el_cgi_principal : "(unset)",
_el_cgi_network,
_el_cgi_engram);
}
/* ── Batch 3: Engram in-process graph store ──────────────────────────────── */
/*
* Single global EngramStore allocated lazily on first call. All node and
* edge content strings are owned (strdup'd) by the store. Linear arrays
* with doubling capacity for both nodes and edges.
*
* Two-layer activation algorithm (engram_activate):
*
* LAYER 1 — Broad fan-out (background activation):
* 1. Find seed nodes whose content/label/tags contain query (case-insens).
* 2. BFS up to `depth` hops along ALL edges (excitatory and inhibitory).
* Every reachable node fires — nothing is filtered at this layer.
* 3. bg_act = seed.salience * temporal_decay * dampening
* propagated as: new_bg = parent_bg * edge_weight * 0.7 * (1 + tbonus)
* where tbonus ∈ {0, 0.10, 0.20} for co-temporal nodes.
* 4. If reached by multiple paths, take max background_activation.
* 5. Persist background_activation to EngramNode.background_activation.
*
* LAYER 2 — Executive filter (working memory promotion):
* 6. For each inhibitory edge where source has background_activation > 0:
* inhibition[target] = max(bg[source] * e->weight)
* 7. For each background-activated node:
* raw_wm = bg * goal_bias(node, query) * confidence
* * (1 - (1 - INHIBITION_FACTOR) * inhibition)
* 8. Per-type threshold gate: raw_wm >= type_threshold → promoted.
* Safety/DharmaSelf: 0.05 Canonical: 0.15 Lesson: 0.25
* Belief/Entity: 0.30 Note/Memory/Working: 0.40
* 9. If not promoted: suppression_count++. After
* ENGRAM_SUPPRESSION_BREAKTHROUGH suppressions → force breakthrough
* at ENGRAM_BREAKTHROUGH_WEIGHT (latent tension surfacing).
* 10. Persist working_memory_weight to EngramNode.working_memory_weight.
* 11. Sort: promoted nodes (wm > 0) first by wm desc, then background-
* only by bg desc. Context compilation uses ONLY promoted nodes.
*
* Temporal decay:
* decay_factor = exp(-lambda * age_hours / T_half)
* T_half = 168.0 h (one week), lambda = ln(2)
*
* Activation dampening:
* dampen = 1.0 / (1.0 + log(1 + activation_count))
*
* engram_query_range(start_ms, end_ms):
* Returns nodes whose created_at OR last_activated falls within
* [start_ms, end_ms], sorted by created_at ascending.
*/
/* Temporal decay constants.
* T_HALF_HOURS: half-life in hours — one week. After one week of no
* activation a node retains 50% of its base salience contribution.
* DECAY_LAMBDA: ln(2) ≈ 0.693147 */
#define ENGRAM_T_HALF_HOURS 168.0
#define ENGRAM_DECAY_LAMBDA 0.693147
/* Two-layer activation constants.
* ENGRAM_WM_THRESHOLD: minimum background_activation for a node to be
* considered for working-memory promotion (layer 2 candidate gate).
* ENGRAM_WM_DECAY: per-turn decay applied to working_memory_weight for
* nodes NOT re-activated in the current turn (conversational thread
* continuity: a node promoted in turn N persists with reduced weight
* into turn N+1 without re-activation cost).
* ENGRAM_SUPPRESSION_BREAKTHROUGH: after this many consecutive suppressions
* a latent node forces itself into working memory at reduced weight,
* modelling the brain's "intrusive thought" / unresolved-tension surfacing.
* ENGRAM_BREAKTHROUGH_WEIGHT: the reduced working_memory_weight assigned
* when a suppressed node breaks through.
* ENGRAM_INHIBITION_FACTOR: multiplier applied to working_memory_weight when
* an inhibitory edge fires against a node (0 = full suppress, 0.3 = partial). */
#define ENGRAM_WM_THRESHOLD 0.15
#define ENGRAM_WM_DECAY 0.7
#define ENGRAM_SUPPRESSION_BREAKTHROUGH 5
#define ENGRAM_BREAKTHROUGH_WEIGHT 0.25
#define ENGRAM_INHIBITION_FACTOR 0.1
/* ── Layered consciousness architecture ──────────────────────────────────────
*
* The engram graph is stratified into LAYERS that gate which suppressions
* apply during the executive filter pass. Layers are ordered shallow-to-deep
* by `activation_priority`; the deepest layer (priority 0, conventionally
* "safety") is the structural floor of the soul: nodes here cannot be
* silenced by inhibitory edges from any other layer. Higher layers
* (core-identity, domain-knowledge, imprint, suit) are normally
* suppressible — they participate in attentional inhibition and goal
* focus the way the prior single-graph implementation did.
*
* The five canonical layers (see engram_init_layers):
* 0. safety — structural, transparent, non-injectable, non-suppressible
* 1. core-identity — default for legacy nodes; suppressible
* 2. domain-knowledge— suppressible
* 3. imprint — runtime-injectable (an Imprint package can add/remove)
* 4. suit — runtime-injectable (a Suit overlays domain skill)
*
* Three-pass activation (engram_activate):
* Pass 1 — Background fan-out: BFS spreads activation across ALL layers
* (existing behavior preserved). Inhibitory edges propagate at
* this layer too; no filtering happens here.
* Pass 2 — Working memory promotion: type-threshold gate, goal bias,
* confidence weighting, inhibitory suppression. Inhibitory edges
* ONLY apply against nodes whose layer is `suppressible == 1`.
* Nodes in non-suppressible layers (Layer 0) ignore inhibition.
* Pass 3 — Layer 0 override: every node in a non-suppressible layer that
* received background activation has its working_memory_weight
* forced to >= ENGRAM_LAYER0_OVERRIDE_WEIGHT. The sacred fire —
* safety nodes that touched any seed unconditionally surface,
* even when the executive filter would have silenced them.
*
* Layer fields:
* suppressible : 0 → inhibitory edges are ignored against nodes in this
* layer during pass 2. Pass 3 also force-promotes them.
* 1 → standard behavior (most layers).
* transparent : 1 → emitted into the prompt context so its content shapes
* output, but filtered out of "what do you know about
* yourself?" introspection queries (engram_search and
* friends do not return transparent-layer nodes by
* default). 0 → fully visible to introspection.
* injectable : 1 → can be added/removed at runtime via engram_add_layer
* and engram_remove_layer (imprints, suits).
* 0 → built-in, fixed at engram_get() initialization.
*
* Backward compatibility:
* Nodes and edges loaded from snapshots without a `layer_id` field default
* to layer 1 (core-identity). The five canonical layers are always present.
*/
#define ENGRAM_LAYER_SAFETY 0u
#define ENGRAM_LAYER_CORE_IDENTITY 1u
#define ENGRAM_LAYER_DOMAIN 2u
#define ENGRAM_LAYER_IMPRINT 3u
#define ENGRAM_LAYER_SUIT 4u
#define ENGRAM_LAYER_DEFAULT ENGRAM_LAYER_CORE_IDENTITY
/* Pass 3 override floor. Layer 0 nodes that received any background
* activation are force-promoted to AT LEAST this working_memory_weight,
* regardless of inhibitory suppression in pass 2. */
#define ENGRAM_LAYER0_OVERRIDE_WEIGHT 1.0
/* Per-node-type activation thresholds.
* Lower tier / safety-critical nodes fire more readily. */
static double engram_type_threshold(const char* node_type, const char* tier) {
if (node_type) {
if (strcmp(node_type, "DharmaSelf") == 0) return 0.05;
if (strcmp(node_type, "Safety") == 0) return 0.05;
}
if (tier) {
if (strcmp(tier, "Canonical") == 0) return 0.15;
if (strcmp(tier, "Lesson") == 0) return 0.25;
}
if (node_type) {
if (strcmp(node_type, "Belief") == 0) return 0.30;
if (strcmp(node_type, "Entity") == 0) return 0.30;
}
return 0.40; /* Note / Memory / Working (most nodes) */
}
typedef struct EngramNode {
char* id;
char* content;
char* node_type;
char* label;
char* tier;
char* tags;
char* metadata;
double salience;
double importance;
double confidence;
double temporal_decay_rate; /* per-node override for lambda; 0 = use default */
int64_t activation_count;
int64_t last_activated;
int64_t created_at;
int64_t updated_at;
/* Two-layer activation fields ─────────────────────────────────────────
* background_activation: Layer 1. Set by BFS fan-out on every query.
* Every reachable node fires here — nothing is filtered at this stage.
* Models the brain's massive parallel sub-threshold activation of all
* associated content in response to a stimulus.
* working_memory_weight: Layer 2. Executive filter output. Only nodes
* that survive goal-state / attentional-bias scoring receive a
* non-zero weight here. Context compilation ONLY uses this field.
* Background-activated nodes with working_memory_weight == 0 remain
* latent — real, available, but silent.
* suppression_count: Consecutive turn count where this node was
* background-activated but NOT promoted to working memory. High
* values signal the node "wants to surface." After
* ENGRAM_SUPPRESSION_BREAKTHROUGH consecutive suppressions the node
* is force-promoted at a reduced weight (breakthrough activation). */
double background_activation;
double working_memory_weight;
int32_t suppression_count;
/* Layered consciousness — see ENGRAM_LAYER_* macros and engram_init_layers.
* Defaults to ENGRAM_LAYER_DEFAULT (1, core-identity) for legacy nodes
* created via engram_node / engram_node_full and for snapshots that
* predate the layered schema. */
uint32_t layer_id;
} EngramNode;
typedef struct EngramEdge {
char* id;
char* from_id;
char* to_id;
char* relation;
char* metadata;
double weight;
double confidence;
int64_t created_at;
int64_t updated_at;
int64_t last_fired;
/* Inhibitory flag: when 1, activating the source node SUPPRESSES the
* working_memory_weight of the target node rather than exciting it.
* Models attentional inhibition: "I am focused on code work" creates
* inhibitory edges to personal/emotional nodes, preventing them from
* surfacing even if they have high background_activation. */
int inhibitory;
/* Layered consciousness — edges carry a layer assignment for
* categorization/visualization. Pass 2 inhibitory gating is decided by
* the TARGET node's layer (whether it's suppressible), not by the edge
* layer. Defaults to ENGRAM_LAYER_DEFAULT. */
uint32_t layer_id;
} EngramEdge;
/* Layered consciousness — runtime layer registry entry. */
typedef struct EngramLayer {
uint32_t layer_id; /* 0 = deepest (safety/limbic) */
char* name; /* persistent — owned by the store */
uint32_t activation_priority; /* lower = fires earlier; safety = 0 */
int suppressible; /* can higher layers suppress nodes here? */
int transparent; /* invisible to introspection queries? */
int injectable; /* can be added/removed at runtime? */
} EngramLayer;
typedef struct EngramStore {
EngramNode* nodes;
int64_t node_count;
int64_t node_capacity;
EngramEdge* edges;
int64_t edge_count;
int64_t edge_capacity;
/* Layer registry — see engram_init_layers. The five canonical layers
* are always present; injectable layers (imprint, suit) are extended
* via engram_add_layer at runtime. layer_id values are assigned
* monotonically; removed injectable layers leave a NULL `name` slot
* (tombstone) so existing layer_id references on nodes stay stable. */
EngramLayer* layers;
size_t layer_count;
size_t layer_capacity;
} EngramStore;
static EngramStore* engram_global = NULL;
/* Initialize the five canonical layers on a fresh store. Called once from
* engram_get(). Layer ids 0..4 are reserved; runtime-injected imprint/suit
* layers (engram_add_layer) get ids 5+. */
static void engram_init_layers(EngramStore* g) {
g->layer_capacity = 16;
g->layers = calloc(g->layer_capacity, sizeof(EngramLayer));
if (!g->layers) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
g->layer_count = 0;
/* Layer 0 — safety. Structural floor. Non-suppressible; transparent
* (filtered out of introspection but still shapes output); not
* runtime-injectable. */
g->layers[g->layer_count++] = (EngramLayer){
.layer_id = ENGRAM_LAYER_SAFETY,
.name = el_strdup_persist("safety"),
.activation_priority = 0,
.suppressible = 0,
.transparent = 1,
.injectable = 0
};
/* Layer 1 — core-identity. The default home for legacy nodes. */
g->layers[g->layer_count++] = (EngramLayer){
.layer_id = ENGRAM_LAYER_CORE_IDENTITY,
.name = el_strdup_persist("core-identity"),
.activation_priority = 10,
.suppressible = 1,
.transparent = 0,
.injectable = 0
};
/* Layer 2 — domain-knowledge. */
g->layers[g->layer_count++] = (EngramLayer){
.layer_id = ENGRAM_LAYER_DOMAIN,
.name = el_strdup_persist("domain-knowledge"),
.activation_priority = 20,
.suppressible = 1,
.transparent = 0,
.injectable = 0
};
/* Layer 3 — imprint. Injectable: an imprint package adds/removes this
* layer (and the nodes assigned to it) as a unit. */
g->layers[g->layer_count++] = (EngramLayer){
.layer_id = ENGRAM_LAYER_IMPRINT,
.name = el_strdup_persist("imprint"),
.activation_priority = 30,
.suppressible = 1,
.transparent = 0,
.injectable = 1
};
/* Layer 4 — suit. Injectable: a Suit overlays domain skill (e.g.
* "enterprise advisor", "divorce lawyer") and can be detached. */
g->layers[g->layer_count++] = (EngramLayer){
.layer_id = ENGRAM_LAYER_SUIT,
.name = el_strdup_persist("suit"),
.activation_priority = 40,
.suppressible = 1,
.transparent = 0,
.injectable = 1
};
}
static EngramStore* engram_get(void) {
if (engram_global) return engram_global;
engram_global = calloc(1, sizeof(EngramStore));
if (!engram_global) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
engram_global->node_capacity = 16;
engram_global->nodes = calloc((size_t)engram_global->node_capacity, sizeof(EngramNode));
engram_global->edge_capacity = 16;
engram_global->edges = calloc((size_t)engram_global->edge_capacity, sizeof(EngramEdge));
engram_init_layers(engram_global);
return engram_global;
}
/* Resolve a layer record by id. Returns NULL if no layer with that id
* exists (e.g. a removed injectable layer or a malformed snapshot). */
static EngramLayer* engram_find_layer(uint32_t layer_id) {
EngramStore* g = engram_get();
for (size_t i = 0; i < g->layer_count; i++) {
EngramLayer* L = &g->layers[i];
if (!L->name) continue; /* tombstone for removed injectable layer */
if (L->layer_id == layer_id) return L;
}
return NULL;
}
/* Resolve a layer record by name. Returns NULL if not found. */
static EngramLayer* engram_find_layer_by_name(const char* name) {
if (!name || !*name) return NULL;
EngramStore* g = engram_get();
for (size_t i = 0; i < g->layer_count; i++) {
EngramLayer* L = &g->layers[i];
if (!L->name) continue;
if (strcmp(L->name, name) == 0) return L;
}
return NULL;
}
/* Allocate the next layer id. Skips ids that are still in use. */
static uint32_t engram_next_layer_id(void) {
EngramStore* g = engram_get();
uint32_t maxid = 0;
for (size_t i = 0; i < g->layer_count; i++) {
if (g->layers[i].layer_id > maxid) maxid = g->layers[i].layer_id;
}
return maxid + 1;
}
/* Whether a node in `layer_id` may be silenced by inhibitory edges in pass 2. */
static int engram_layer_is_suppressible(uint32_t layer_id) {
EngramLayer* L = engram_find_layer(layer_id);
if (!L) return 1; /* unknown layer → safe default: standard suppression */
return L->suppressible ? 1 : 0;
}
/* Whether a layer is transparent (its content shapes output but is filtered
* from introspection queries). Currently used to mark Layer 0 as invisible
* to "what do you know about yourself" lookups while still letting it
* dominate the prompt context. */
static int engram_layer_is_transparent(uint32_t layer_id) {
EngramLayer* L = engram_find_layer(layer_id);
if (!L) return 0;
return L->transparent ? 1 : 0;
}
static int64_t engram_now_ms(void) {
struct timeval tv; gettimeofday(&tv, NULL);
return (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
}
static EngramNode* engram_find_node(const char* id) {
if (!id) return NULL;
EngramStore* g = engram_get();
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].id && strcmp(g->nodes[i].id, id) == 0) return &g->nodes[i];
}
return NULL;
}
static int64_t engram_find_node_index(const char* id) {
if (!id) return -1;
EngramStore* g = engram_get();
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].id && strcmp(g->nodes[i].id, id) == 0) return i;
}
return -1;
}
static void engram_grow_nodes(void) {
EngramStore* g = engram_get();
if (g->node_count < g->node_capacity) return;
int64_t nc = g->node_capacity * 2;
g->nodes = realloc(g->nodes, (size_t)nc * sizeof(EngramNode));
if (!g->nodes) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
memset(g->nodes + g->node_capacity, 0,
(size_t)(nc - g->node_capacity) * sizeof(EngramNode));
g->node_capacity = nc;
}
static void engram_grow_edges(void) {
EngramStore* g = engram_get();
if (g->edge_count < g->edge_capacity) return;
int64_t nc = g->edge_capacity * 2;
g->edges = realloc(g->edges, (size_t)nc * sizeof(EngramEdge));
if (!g->edges) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
memset(g->edges + g->edge_capacity, 0,
(size_t)(nc - g->edge_capacity) * sizeof(EngramEdge));
g->edge_capacity = nc;
}
/* Build a fresh UUID string. Reuses uuid_new but takes the underlying char*. */
static char* engram_new_id(void) {
el_val_t v = uuid_new();
const char* s = EL_CSTR(v);
return el_strdup(s ? s : "");
}
/* Convert a node into an ElMap of its fields. */
static el_val_t engram_node_to_map(const EngramNode* n) {
el_val_t m = el_map_new(0);
m = el_map_set(m, EL_STR(el_strdup("id")), EL_STR(el_strdup(n->id ? n->id : "")));
m = el_map_set(m, EL_STR(el_strdup("content")), EL_STR(el_strdup(n->content ? n->content : "")));
m = el_map_set(m, EL_STR(el_strdup("node_type")), EL_STR(el_strdup(n->node_type ? n->node_type : "")));
m = el_map_set(m, EL_STR(el_strdup("label")), EL_STR(el_strdup(n->label ? n->label : "")));
m = el_map_set(m, EL_STR(el_strdup("tier")), EL_STR(el_strdup(n->tier ? n->tier : "Working")));
m = el_map_set(m, EL_STR(el_strdup("tags")), EL_STR(el_strdup(n->tags ? n->tags : "")));
m = el_map_set(m, EL_STR(el_strdup("metadata")), EL_STR(el_strdup(n->metadata ? n->metadata : "{}")));
m = el_map_set(m, EL_STR(el_strdup("salience")), el_from_float(n->salience));
m = el_map_set(m, EL_STR(el_strdup("importance")), el_from_float(n->importance));
m = el_map_set(m, EL_STR(el_strdup("confidence")), el_from_float(n->confidence));
m = el_map_set(m, EL_STR(el_strdup("temporal_decay_rate")), el_from_float(n->temporal_decay_rate));
m = el_map_set(m, EL_STR(el_strdup("activation_count")), (el_val_t)n->activation_count);
m = el_map_set(m, EL_STR(el_strdup("last_activated")), (el_val_t)n->last_activated);
m = el_map_set(m, EL_STR(el_strdup("created_at")), (el_val_t)n->created_at);
m = el_map_set(m, EL_STR(el_strdup("updated_at")), (el_val_t)n->updated_at);
m = el_map_set(m, EL_STR(el_strdup("background_activation")), el_from_float(n->background_activation));
m = el_map_set(m, EL_STR(el_strdup("working_memory_weight")), el_from_float(n->working_memory_weight));
m = el_map_set(m, EL_STR(el_strdup("suppression_count")), (el_val_t)n->suppression_count);
m = el_map_set(m, EL_STR(el_strdup("layer_id")), (el_val_t)(int64_t)n->layer_id);
return m;
}
/* (Node JSON serialization is provided by `engram_emit_node_json` further
* down in the persistence section — reused by the *_json builtins below.) */
static void engram_emit_node_json(JsonBuf* b, const EngramNode* n);
static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e);
/* Salience may arrive either as a float bit-pattern or as a small integer
* (e.g. 1, meaning 1.0). Heuristic: if interpreted as double it's in
* [0.0, 100.0] use it; otherwise treat as int and convert. */
static double engram_decode_score(el_val_t v) {
double f = el_to_float(v);
if (!isnan(f) && !isinf(f) && f >= 0.0 && f <= 100.0) return f;
int64_t n = (int64_t)v;
return (double)n;
}
static char* engram_first_n_chars(const char* s, size_t n) {
if (!s) return el_strdup("");
size_t l = strlen(s);
if (l > n) l = n;
char* out = el_strbuf(l);
memcpy(out, s, l);
out[l] = '\0';
return out;
}
el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
EngramStore* g = engram_get();
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = engram_new_id();
const char* c = EL_CSTR(content);
const char* nt = EL_CSTR(node_type);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = engram_first_n_chars(c, 60);
n->tier = el_strdup("Working");
n->tags = el_strdup("");
n->metadata = el_strdup("{}");
n->salience = engram_decode_score(salience);
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
n->importance = 0.5;
n->confidence = 1.0;
n->temporal_decay_rate = 0.0; /* 0 = use global default ENGRAM_DECAY_LAMBDA */
n->activation_count = 0;
int64_t now = engram_now_ms();
n->last_activated = now;
n->created_at = now;
n->updated_at = now;
n->layer_id = ENGRAM_LAYER_DEFAULT;
g->node_count++;
return el_wrap_str(el_strdup(n->id));
}
el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
el_val_t salience, el_val_t importance, el_val_t confidence,
el_val_t tier, el_val_t tags) {
EngramStore* g = engram_get();
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = engram_new_id();
const char* c = EL_CSTR(content);
const char* nt = EL_CSTR(node_type);
const char* lb = EL_CSTR(label);
const char* ti = EL_CSTR(tier);
const char* tg = EL_CSTR(tags);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = el_strdup(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup(ti && *ti ? ti : "Working");
n->tags = el_strdup(tg ? tg : "");
n->metadata = el_strdup("{}");
n->salience = engram_decode_score(salience);
n->importance = engram_decode_score(importance);
n->confidence = engram_decode_score(confidence);
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
if (n->importance <= 0.0 || n->importance > 1.0) n->importance = 0.5;
if (n->confidence <= 0.0 || n->confidence > 1.0) n->confidence = 1.0;
n->temporal_decay_rate = 0.0; /* 0 = use global default ENGRAM_DECAY_LAMBDA */
n->activation_count = 0;
int64_t now = engram_now_ms();
n->last_activated = now;
n->created_at = now;
n->updated_at = now;
n->layer_id = ENGRAM_LAYER_DEFAULT;
g->node_count++;
return el_wrap_str(el_strdup(n->id));
}
/* engram_node_layered — like engram_node_full but with explicit layer
* assignment and an additional `status` slot reserved for callers that
* track lifecycle state in metadata. The signature mirrors the public API
* defined in the layered consciousness design doc:
*
* engram_node_layered(content, node_type, label,
* salience, certainty, confidence,
* status, tags, layer_id)
*
* `certainty` is folded into `importance` (it occupies the same axis in
* the existing schema). `status` is recorded under metadata.status; an
* empty status leaves metadata as the default "{}".
*
* If `layer_id` does not resolve to a known layer the call falls back to
* ENGRAM_LAYER_DEFAULT — better to keep the node addressable than to drop
* it because of a stale layer reference. Callers wanting strict validation
* should engram_list_layers first. */
el_val_t engram_node_layered(el_val_t content, el_val_t node_type, el_val_t label,
el_val_t salience, el_val_t certainty, el_val_t confidence,
el_val_t status, el_val_t tags, el_val_t layer_id) {
EngramStore* g = engram_get();
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = engram_new_id();
const char* c = EL_CSTR(content);
const char* nt = EL_CSTR(node_type);
const char* lb = EL_CSTR(label);
const char* tg = EL_CSTR(tags);
const char* st = EL_CSTR(status);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = el_strdup(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup("Working");
n->tags = el_strdup(tg ? tg : "");
if (st && *st) {
/* Minimal metadata payload: {"status":"..."}. Keep it cheap so
* callers using `status` don't pay JSON parse cost on every read. */
size_t sl = strlen(st) + 16;
char* meta = el_strbuf(sl);
snprintf(meta, sl, "{\"status\":\"%s\"}", st);
n->metadata = meta;
} else {
n->metadata = el_strdup("{}");
}
n->salience = engram_decode_score(salience);
n->importance = engram_decode_score(certainty);
n->confidence = engram_decode_score(confidence);
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
if (n->importance <= 0.0 || n->importance > 1.0) n->importance = 0.5;
if (n->confidence <= 0.0 || n->confidence > 1.0) n->confidence = 1.0;
n->temporal_decay_rate = 0.0;
n->activation_count = 0;
int64_t now = engram_now_ms();
n->last_activated = now;
n->created_at = now;
n->updated_at = now;
/* Resolve layer assignment. Caller passes either a numeric layer_id or
* a stringified id; el_to_float / int cast tolerates both. */
int64_t lid = (int64_t)layer_id;
if (lid < 0) lid = (int64_t)ENGRAM_LAYER_DEFAULT;
if (!engram_find_layer((uint32_t)lid)) lid = (int64_t)ENGRAM_LAYER_DEFAULT;
n->layer_id = (uint32_t)lid;
g->node_count++;
return el_wrap_str(el_strdup(n->id));
}
/* ── Layer registry public API ──────────────────────────────────────────────
*
* The five canonical layers are seeded at engram_get() initialization.
* Runtime code (typically imprint/suit injection logic at the EL level)
* can extend the registry with engram_add_layer() — only layers marked
* `injectable=1` may be removed via engram_remove_layer(). Removing a
* layer leaves a tombstone slot so existing layer_id references on nodes
* stay valid; orphaned references resolve to "unknown layer" and inherit
* the default suppression behavior.
*/
/* engram_add_layer — register a new layer at runtime.
* Returns the assigned layer_id as an el_val_t int (cast back via int64_t).
* Conflicting names are rejected (returns 0). */
el_val_t engram_add_layer(el_val_t name, el_val_t priority, el_val_t suppressible,
el_val_t transparent, el_val_t injectable) {
EngramStore* g = engram_get();
const char* nm = EL_CSTR(name);
if (!nm || !*nm) return (el_val_t)0;
if (engram_find_layer_by_name(nm)) {
/* Name collision — return existing id so callers are idempotent. */
return (el_val_t)(int64_t)engram_find_layer_by_name(nm)->layer_id;
}
if (g->layer_count >= g->layer_capacity) {
size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
EngramLayer* grown = realloc(g->layers, nc * sizeof(EngramLayer));
if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
memset(grown + g->layer_capacity, 0,
(nc - g->layer_capacity) * sizeof(EngramLayer));
g->layers = grown;
g->layer_capacity = nc;
}
EngramLayer* L = &g->layers[g->layer_count++];
L->layer_id = engram_next_layer_id();
L->name = el_strdup_persist(nm);
L->activation_priority = (uint32_t)(int64_t)priority;
L->suppressible = (int)(int64_t)suppressible ? 1 : 0;
L->transparent = (int)(int64_t)transparent ? 1 : 0;
L->injectable = (int)(int64_t)injectable ? 1 : 0;
return (el_val_t)(int64_t)L->layer_id;
}
/* engram_remove_layer — remove an injectable layer by id.
* Built-in (non-injectable) layers cannot be removed. Nodes still tagged
* with the removed layer's id keep their tag but resolve to "unknown
* layer" thereafter and inherit standard (suppressible) behavior.
* Returns 1 on success, 0 on failure (unknown id, non-injectable). */
el_val_t engram_remove_layer(el_val_t layer_id) {
EngramStore* g = engram_get();
int64_t lid = (int64_t)layer_id;
for (size_t i = 0; i < g->layer_count; i++) {
EngramLayer* L = &g->layers[i];
if (!L->name) continue;
if ((int64_t)L->layer_id != lid) continue;
if (!L->injectable) return (el_val_t)0;
free(L->name);
L->name = NULL; /* tombstone */
/* Leave layer_id, priority, flags intact so debug snapshots can
* still distinguish "removed at runtime" from "never existed". */
return (el_val_t)1;
}
return (el_val_t)0;
}
/* engram_list_layers — enumerate the active layer registry.
* Returns an ElList of maps, one per non-tombstone layer, sorted by
* activation_priority ascending (deepest layer first). */
el_val_t engram_list_layers(void) {
EngramStore* g = engram_get();
el_val_t lst = el_list_empty();
if (g->layer_count == 0) return lst;
/* Build an index sorted by activation_priority ascending. */
size_t* idx = malloc(g->layer_count * sizeof(size_t));
if (!idx) return lst;
size_t live = 0;
for (size_t i = 0; i < g->layer_count; i++) {
if (g->layers[i].name) idx[live++] = i;
}
/* Insertion sort — N is small (≤ a few dozen layers). */
for (size_t i = 1; i < live; i++) {
size_t key = idx[i];
uint32_t kp = g->layers[key].activation_priority;
size_t j = i;
while (j > 0 && g->layers[idx[j - 1]].activation_priority > kp) {
idx[j] = idx[j - 1];
j--;
}
idx[j] = key;
}
for (size_t i = 0; i < live; i++) {
EngramLayer* L = &g->layers[idx[i]];
el_val_t m = el_map_new(0);
m = el_map_set(m, EL_STR(el_strdup("layer_id")),
(el_val_t)(int64_t)L->layer_id);
m = el_map_set(m, EL_STR(el_strdup("name")),
EL_STR(el_strdup(L->name ? L->name : "")));
m = el_map_set(m, EL_STR(el_strdup("activation_priority")),
(el_val_t)(int64_t)L->activation_priority);
m = el_map_set(m, EL_STR(el_strdup("suppressible")),
(el_val_t)(int64_t)(L->suppressible ? 1 : 0));
m = el_map_set(m, EL_STR(el_strdup("transparent")),
(el_val_t)(int64_t)(L->transparent ? 1 : 0));
m = el_map_set(m, EL_STR(el_strdup("injectable")),
(el_val_t)(int64_t)(L->injectable ? 1 : 0));
lst = el_list_append(lst, m);
}
free(idx);
return lst;
}
el_val_t engram_get_node(el_val_t id) {
const char* sid = EL_CSTR(id);
EngramNode* n = engram_find_node(sid);
if (!n) return el_map_new(0);
return engram_node_to_map(n);
}
void engram_strengthen(el_val_t node_id) {
const char* sid = EL_CSTR(node_id);
EngramNode* n = engram_find_node(sid);
if (!n) return;
n->salience += 0.05;
if (n->salience > 1.0) n->salience = 1.0;
n->activation_count++;
n->last_activated = engram_now_ms();
n->updated_at = n->last_activated;
}
void engram_forget(el_val_t node_id) {
const char* sid = EL_CSTR(node_id);
if (!sid) return;
EngramStore* g = engram_get();
int64_t idx = engram_find_node_index(sid);
if (idx < 0) return;
/* Free node strings */
EngramNode* n = &g->nodes[idx];
free(n->id); free(n->content); free(n->node_type); free(n->label);
free(n->tier); free(n->tags); free(n->metadata);
/* Shift remaining nodes down */
for (int64_t i = idx + 1; i < g->node_count; i++) {
g->nodes[i - 1] = g->nodes[i];
}
g->node_count--;
memset(&g->nodes[g->node_count], 0, sizeof(EngramNode));
/* Remove all incident edges */
int64_t w = 0;
for (int64_t r = 0; r < g->edge_count; r++) {
EngramEdge* e = &g->edges[r];
int incident = (e->from_id && strcmp(e->from_id, sid) == 0) ||
(e->to_id && strcmp(e->to_id, sid) == 0);
if (incident) {
free(e->id); free(e->from_id); free(e->to_id);
free(e->relation); free(e->metadata);
} else {
if (w != r) g->edges[w] = g->edges[r];
w++;
}
}
g->edge_count = w;
}
el_val_t engram_node_count(void) {
return (el_val_t)engram_get()->node_count;
}
static int istr_contains(const char* hay, const char* needle) {
if (!hay || !needle || !*needle) return 0;
size_t nl = strlen(needle);
for (const char* p = hay; *p; p++) {
if (strncasecmp(p, needle, nl) == 0) return 1;
}
return 0;
}
el_val_t engram_search(el_val_t query, el_val_t limit) {
EngramStore* g = engram_get();
const char* q = EL_CSTR(query);
int64_t lim = (int64_t)limit;
if (lim <= 0) lim = 100;
el_val_t lst = el_list_empty();
if (!q || !*q) return lst;
int64_t found = 0;
for (int64_t i = 0; i < g->node_count && found < lim; i++) {
EngramNode* n = &g->nodes[i];
/* Filter transparent layers: nodes whose layer is `transparent=1`
* shape output but are invisible to introspection ("what do you
* know about yourself"). They still surface via engram_activate
* + engram_compile_layered_json — that's the legitimate path. */
if (engram_layer_is_transparent(n->layer_id)) continue;
if (istr_contains(n->content, q) ||
istr_contains(n->label, q) ||
istr_contains(n->tags, q)) {
lst = el_list_append(lst, engram_node_to_map(n));
found++;
}
}
return lst;
}
/* Sort node indices by salience desc (small N, insertion sort is fine). */
static void engram_sort_indices_by_salience(int64_t* arr, int64_t n,
const EngramNode* nodes) {
for (int64_t i = 1; i < n; i++) {
int64_t key = arr[i];
double ks = nodes[key].salience;
int64_t j = i - 1;
while (j >= 0 && nodes[arr[j]].salience < ks) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset) {
EngramStore* g = engram_get();
int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
int64_t off = (int64_t)offset; if (off < 0) off = 0;
el_val_t lst = el_list_empty();
if (g->node_count == 0) return lst;
int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
if (!idx) return lst;
/* Skip transparent layers — same introspection-filter rationale as
* engram_search above. */
int64_t live = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
idx[live++] = i;
}
engram_sort_indices_by_salience(idx, live, g->nodes);
int64_t end = off + lim;
if (end > live) end = live;
for (int64_t i = off; i < end; i++) {
lst = el_list_append(lst, engram_node_to_map(&g->nodes[idx[i]]));
}
free(idx);
return lst;
}
void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
EngramStore* g = engram_get();
const char* f = EL_CSTR(from_id);
const char* t = EL_CSTR(to_id);
const char* r = EL_CSTR(relation);
if (!f || !t) return;
engram_grow_edges();
EngramEdge* e = &g->edges[g->edge_count];
memset(e, 0, sizeof(*e));
e->id = engram_new_id();
e->from_id = el_strdup(f);
e->to_id = el_strdup(t);
e->relation = el_strdup(r && *r ? r : "associate");
e->metadata = el_strdup("{}");
e->weight = engram_decode_score(weight);
if (e->weight <= 0.0 || e->weight > 1.0) e->weight = 0.5;
e->confidence = 1.0;
int64_t now = engram_now_ms();
e->created_at = now;
e->updated_at = now;
e->last_fired = 0;
e->layer_id = ENGRAM_LAYER_DEFAULT;
g->edge_count++;
}
el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id) {
EngramStore* g = engram_get();
const char* f = EL_CSTR(from_id);
const char* t = EL_CSTR(to_id);
if (!f || !t) return 0;
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
if (e->from_id && e->to_id &&
strcmp(e->from_id, f) == 0 && strcmp(e->to_id, t) == 0) return 1;
}
return 0;
}
/* Reserved helper: edge -> ElMap. Kept around for future builtins. */
static el_val_t engram_edge_to_map(const EngramEdge* e) __attribute__((unused));
static el_val_t engram_edge_to_map(const EngramEdge* e) {
el_val_t m = el_map_new(0);
m = el_map_set(m, EL_STR(el_strdup("id")), EL_STR(el_strdup(e->id ? e->id : "")));
m = el_map_set(m, EL_STR(el_strdup("from_id")), EL_STR(el_strdup(e->from_id ? e->from_id : "")));
m = el_map_set(m, EL_STR(el_strdup("to_id")), EL_STR(el_strdup(e->to_id ? e->to_id : "")));
m = el_map_set(m, EL_STR(el_strdup("relation")), EL_STR(el_strdup(e->relation ? e->relation : "")));
m = el_map_set(m, EL_STR(el_strdup("metadata")), EL_STR(el_strdup(e->metadata ? e->metadata : "{}")));
m = el_map_set(m, EL_STR(el_strdup("weight")), el_from_float(e->weight));
m = el_map_set(m, EL_STR(el_strdup("confidence")), el_from_float(e->confidence));
m = el_map_set(m, EL_STR(el_strdup("created_at")), (el_val_t)e->created_at);
m = el_map_set(m, EL_STR(el_strdup("updated_at")), (el_val_t)e->updated_at);
m = el_map_set(m, EL_STR(el_strdup("last_fired")), (el_val_t)e->last_fired);
m = el_map_set(m, EL_STR(el_strdup("inhibitory")), (el_val_t)(e->inhibitory ? 1 : 0));
m = el_map_set(m, EL_STR(el_strdup("layer_id")), (el_val_t)(int64_t)e->layer_id);
return m;
}
el_val_t engram_neighbors(el_val_t node_id) {
EngramStore* g = engram_get();
const char* sid = EL_CSTR(node_id);
el_val_t lst = el_list_empty();
if (!sid) return lst;
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
const char* other = NULL;
if (e->from_id && strcmp(e->from_id, sid) == 0) other = e->to_id;
else if (e->to_id && strcmp(e->to_id, sid) == 0) other = e->from_id;
if (!other) continue;
EngramNode* n = engram_find_node(other);
if (n) lst = el_list_append(lst, engram_node_to_map(n));
}
return lst;
}
el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
EngramStore* g = engram_get();
const char* sid = EL_CSTR(node_id);
int64_t md = (int64_t)max_depth; if (md <= 0) md = 1;
const char* dir = EL_CSTR(direction); /* "out" | "in" | "both" (default) */
el_val_t lst = el_list_empty();
if (!sid || g->node_count == 0) return lst;
int64_t start = engram_find_node_index(sid);
if (start < 0) return lst;
/* BFS with depth tracking */
int64_t* visited = calloc((size_t)g->node_count, sizeof(int64_t));
int64_t* queue = calloc((size_t)g->node_count, sizeof(int64_t));
int64_t* depths = calloc((size_t)g->node_count, sizeof(int64_t));
if (!visited || !queue || !depths) {
free(visited); free(queue); free(depths); return lst;
}
int64_t qh = 0, qt = 0;
queue[qt++] = start;
visited[start] = 1;
depths[start] = 0;
while (qh < qt) {
int64_t cur = queue[qh++];
const char* cur_id = g->nodes[cur].id;
int64_t cur_depth = depths[cur];
if (cur_depth >= md) continue;
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
const char* other = NULL;
int outgoing = e->from_id && strcmp(e->from_id, cur_id) == 0;
int incoming = e->to_id && strcmp(e->to_id, cur_id) == 0;
if (dir && strcmp(dir, "out") == 0 && !outgoing) continue;
if (dir && strcmp(dir, "in") == 0 && !incoming) continue;
if (outgoing) other = e->to_id;
else if (incoming) other = e->from_id;
else continue;
int64_t oi = engram_find_node_index(other);
if (oi < 0 || visited[oi]) continue;
visited[oi] = 1;
depths[oi] = cur_depth + 1;
queue[qt++] = oi;
}
}
/* Emit all visited except the seed */
for (int64_t i = 0; i < g->node_count; i++) {
if (visited[i] && i != start) {
lst = el_list_append(lst, engram_node_to_map(&g->nodes[i]));
}
}
free(visited); free(queue); free(depths);
return lst;
}
el_val_t engram_edge_count(void) {
return (el_val_t)engram_get()->edge_count;
}
/* Compute temporal decay factor for a node given current time.
* effective contribution = salience * exp(-lambda * age_hours / T_half)
* Clamped to [0.05, 1.0] so very old nodes retain a meaningful floor. */
static double engram_temporal_decay(const EngramNode* n, int64_t now_ms) {
int64_t age_ms = now_ms - n->last_activated;
if (age_ms <= 0) return 1.0;
double lambda = (n->temporal_decay_rate > 0.0) ? n->temporal_decay_rate
: ENGRAM_DECAY_LAMBDA;
double age_hours = (double)age_ms / 3600000.0;
double factor = exp(-lambda * age_hours / ENGRAM_T_HALF_HOURS);
if (factor < 0.05) factor = 0.05;
return factor;
}
/* Activation dampening: high activation_count nodes are "well-known" context
* and get less marginal boost per firing.
* count=0 → 1.0, count=2 → ~0.74, count=9 → ~0.59, count=99 → ~0.43 */
static double engram_activation_dampen(const EngramNode* n) {
return 1.0 / (1.0 + log(1.0 + (double)n->activation_count));
}
/* Temporal proximity bonus: boost propagation along edges connecting
* co-temporal nodes. Returns a multiplier bonus in [0, 0.2]. */
static double engram_temporal_proximity_bonus(int64_t node_created,
int64_t seed_epoch) {
int64_t diff = node_created - seed_epoch;
if (diff < 0) diff = -diff;
if (diff < 86400000LL) return 0.20; /* within 1 day */
if (diff < 604800000LL) return 0.10; /* within 7 days */
return 0.0;
}
/* ── Two-layer activation (biologically-motivated) ───────────────────────────
*
* Layer 1 — Broad fan-out (background activation):
* BFS + spreading activation fires on ALL nodes reachable from seeds,
* regardless of relevance to the current goal. Every reachable node gets
* a background_activation score. Nothing is filtered here. Models the
* brain's massive parallel sub-threshold activation of all associated
* content in response to a stimulus. Temporal decay and activation
* dampening are applied at this layer (as before), but no threshold gate.
*
* Layer 2 — Executive filter (working memory promotion):
* A second pass asks: given the query (goal intent), attentional bias,
* and inhibitory edge topology — which background-activated nodes should
* break through into working memory?
*
* wm_weight = bg_activation * goal_bias(node, query) * confidence
* * inhibitory_suppression_factor
*
* Only nodes where wm_weight >= ENGRAM_WM_THRESHOLD are promoted to
* working memory (working_memory_weight > 0). Background-activated nodes
* that don't cross the threshold accumulate suppression_count. After
* ENGRAM_SUPPRESSION_BREAKTHROUGH consecutive suppressed turns, the node
* force-breaks through at ENGRAM_BREAKTHROUGH_WEIGHT (latent tension
* surfacing — models intrusive memory / unresolved cognitive load).
*
* Inhibitory edges:
* An edge with inhibitory=1 suppresses the TARGET node's working memory
* promotion when the SOURCE is background-activated. Background activation
* of the target is NOT affected — the node fires in layer 1. Only the
* executive filter (layer 2) is gated. Models attentional inhibition:
* "focused on code work" suppresses personal memories from surfacing
* even if they have high background_activation.
*
* Goal bias:
* A lightweight heuristic rates how well each background-activated node
* aligns with the apparent intent of the current query. Technical queries
* boost Belief/Canonical/Lesson nodes; relational queries boost Memory/
* Entity nodes. Direct lexical overlap gives a 50% bonus.
*
* Working memory persistence (turn continuity):
* Nodes promoted in the previous turn retain a decayed working_memory_weight
* (weight *= ENGRAM_WM_DECAY) without needing re-activation. This models
* conversational thread continuity — once a topic is in working memory,
* it persists slightly into the next turn.
*
* Returns ElList of {node, activation_strength, working_memory_weight,
* epistemic_confidence, hops, promoted}.
* "promoted" = 1 if working_memory_weight > 0, 0 if background-only.
* Context compilation uses ONLY nodes with promoted=1.
*
* Temporal decay (preserved from prior implementation):
* effective_salience = salience * exp(-lambda * age_hours / T_half)
* where T_half = 168 h (one week), lambda = ln(2)
*
* Activation dampening (preserved):
* dampen = 1 / (1 + log(1 + activation_count))
*
* Temporal proximity bonus (preserved):
* edge_strength *= (1 + tbonus) where tbonus ∈ {0, 0.10, 0.20}
*
* Per-type threshold gates apply only to working memory promotion (layer 2):
* Safety/DharmaSelf: 0.05 Canonical: 0.15 Lesson: 0.25
* Belief/Entity: 0.30 Note/Memory/Working: 0.40
*/
/* Compute goal-state bias multiplier for a node given the query.
* Returns a value in [0.3, 2.0]. This is a lightweight heuristic —
* a production implementation may use LLM-derived intent classification. */
static double engram_goal_bias(const EngramNode* n, const char* query) {
if (!query || !*query) return 1.0;
double bias = 1.0;
/* Direct lexical overlap: node content/label/tags share text with query. */
if (istr_contains(n->content, query) || istr_contains(n->label, query) ||
istr_contains(n->tags, query)) {
bias += 0.5;
}
/* Node-type resonance with query intent. */
int technical_query = istr_contains(query, "code") ||
istr_contains(query, "function") ||
istr_contains(query, "implement") ||
istr_contains(query, "error") ||
istr_contains(query, "bug") ||
istr_contains(query, "build") ||
istr_contains(query, "system") ||
istr_contains(query, "design") ||
istr_contains(query, "architecture");
int personal_query = istr_contains(query, "feel") ||
istr_contains(query, "emotion") ||
istr_contains(query, "remember") ||
istr_contains(query, "personal") ||
istr_contains(query, "story") ||
istr_contains(query, "relationship");
if (n->node_type) {
int is_knowledge = (strcmp(n->node_type, "Belief") == 0) ||
(strcmp(n->node_type, "DharmaSelf") == 0) ||
(strcmp(n->node_type, "Safety") == 0);
int is_personal = (strcmp(n->node_type, "Memory") == 0) ||
(strcmp(n->node_type, "Entity") == 0);
if (technical_query && is_knowledge) bias += 0.3;
if (technical_query && is_personal) bias -= 0.3;
if (personal_query && is_personal) bias += 0.3;
if (personal_query && is_knowledge) bias -= 0.1;
}
/* Tier-based bonus: promote higher-confidence knowledge nodes. */
if (n->tier) {
if (strcmp(n->tier, "Canonical") == 0) bias += 0.2;
if (strcmp(n->tier, "Lesson") == 0) bias += 0.1;
}
if (bias < 0.3) bias = 0.3;
if (bias > 2.0) bias = 2.0;
return bias;
}
el_val_t engram_activate(el_val_t query, el_val_t depth) {
EngramStore* g = engram_get();
const char* q = EL_CSTR(query);
int64_t max_depth = (int64_t)depth; if (max_depth <= 0) max_depth = 2;
el_val_t out = el_list_empty();
if (!q || g->node_count == 0) return out;
int64_t now_ms = engram_now_ms();
/* Per-node layer-1 tracking. */
double* best_bg = calloc((size_t)g->node_count, sizeof(double));
int64_t* best_hops = calloc((size_t)g->node_count, sizeof(int64_t));
int* reached = calloc((size_t)g->node_count, sizeof(int));
if (!best_bg || !best_hops || !reached) {
free(best_bg); free(best_hops); free(reached); return out;
}
/* ── LAYER 1: broad fan-out (background activation) ─────────────────
* Find seeds, apply temporal decay + dampening, BFS with edge weights.
* Inhibitory edges propagate activation normally at this layer — they
* only gate working memory promotion in layer 2. */
typedef struct { int64_t idx; double act; int64_t created_at; } SeedEntry;
SeedEntry* seeds = malloc((size_t)g->node_count * sizeof(SeedEntry));
int64_t seed_count = 0;
if (!seeds) {
free(best_bg); free(best_hops); free(reached); return out;
}
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (istr_contains(n->content, q) ||
istr_contains(n->label, q) ||
istr_contains(n->tags, q)) {
double tdecay = engram_temporal_decay(n, now_ms);
double dampen = engram_activation_dampen(n);
double act = n->salience * tdecay * dampen;
seeds[seed_count].idx = i;
seeds[seed_count].act = act;
seeds[seed_count].created_at = n->created_at;
seed_count++;
best_bg[i] = act;
best_hops[i] = 0;
reached[i] = 1;
}
}
/* Compute mean seed created_at for temporal proximity bonus. */
int64_t seed_epoch = 0;
if (seed_count > 0) {
seed_epoch = seeds[0].created_at;
for (int64_t s = 1; s < seed_count; s++)
seed_epoch = (seed_epoch + seeds[s].created_at) / 2;
}
typedef struct { int64_t idx; int64_t hops; double act; } Frontier;
Frontier* fr = malloc((size_t)(g->node_count * (max_depth + 1)) * sizeof(Frontier) + 16 * sizeof(Frontier));
if (!fr) {
free(best_bg); free(best_hops); free(reached); free(seeds); return out;
}
int64_t fhead = 0, ftail = 0;
int64_t fcap = (int64_t)((size_t)(g->node_count * (max_depth + 1)) + 16);
for (int64_t s = 0; s < seed_count; s++) {
if (ftail >= fcap) break;
fr[ftail].idx = seeds[s].idx;
fr[ftail].hops = 0;
fr[ftail].act = seeds[s].act;
ftail++;
}
const double SPREAD_DECAY = 0.7;
while (fhead < ftail) {
Frontier f = fr[fhead++];
if (f.hops >= max_depth) continue;
const char* cur_id = g->nodes[f.idx].id;
for (int64_t ei = 0; ei < g->edge_count; ei++) {
EngramEdge* e = &g->edges[ei];
const char* other = NULL;
if (e->from_id && strcmp(e->from_id, cur_id) == 0) other = e->to_id;
else if (e->to_id && strcmp(e->to_id, cur_id) == 0) other = e->from_id;
else continue;
int64_t oi = engram_find_node_index(other);
if (oi < 0) continue;
EngramNode* on = &g->nodes[oi];
double tbonus = engram_temporal_proximity_bonus(on->created_at, seed_epoch);
double tdecay = engram_temporal_decay(on, now_ms);
double dampen = engram_activation_dampen(on);
double new_act = f.act * e->weight * SPREAD_DECAY * (1.0 + tbonus)
* tdecay * dampen;
int64_t new_hops = f.hops + 1;
if (!reached[oi] || new_act > best_bg[oi]) {
best_bg[oi] = new_act;
best_hops[oi] = new_hops;
reached[oi] = 1;
if (ftail < fcap) {
fr[ftail].idx = oi;
fr[ftail].hops = new_hops;
fr[ftail].act = new_act;
ftail++;
}
}
}
}
/* Persist layer-1 background_activation to node store. */
for (int64_t i = 0; i < g->node_count; i++) {
g->nodes[i].background_activation = reached[i] ? best_bg[i] : 0.0;
}
/* ── PASS 2: executive filter → working memory promotion ──────────── */
/* Step A: collect inhibitory suppressions from fired inhibitory edges.
* Layered consciousness: inhibition is ONLY recorded against targets
* whose layer is `suppressible == 1`. Nodes in non-suppressible layers
* (Layer 0 / safety) ignore inhibitory edges entirely — their working
* memory weight cannot be silenced by attentional suppression. */
double* inhibition = calloc((size_t)g->node_count, sizeof(double));
if (!inhibition) {
free(best_bg); free(best_hops); free(reached); free(seeds); free(fr);
return out;
}
for (int64_t ei = 0; ei < g->edge_count; ei++) {
EngramEdge* e = &g->edges[ei];
if (!e->inhibitory) continue;
int64_t src = engram_find_node_index(e->from_id);
int64_t tgt = engram_find_node_index(e->to_id);
if (src < 0 || tgt < 0) continue;
if (!reached[src] || best_bg[src] <= 0.0) continue;
/* Skip if target layer is non-suppressible: Layer 0 / safety nodes
* are immune to inhibitory edges from any source. The pass-3
* override below also force-promotes them, but recording inhibition
* against them at all would be wasted work and could confuse
* downstream debugging output. */
if (!engram_layer_is_suppressible(g->nodes[tgt].layer_id)) continue;
/* Inhibition strength proportional to source background activation
* and edge weight. Takes the maximum if multiple inhibitory edges
* target the same node. */
double inh = best_bg[src] * e->weight;
if (inh > inhibition[tgt]) inhibition[tgt] = inh;
}
/* Step B: compute working_memory_weight per candidate node. */
double* wm_weights = calloc((size_t)g->node_count, sizeof(double));
if (!wm_weights) {
free(best_bg); free(best_hops); free(reached); free(seeds);
free(fr); free(inhibition); return out;
}
for (int64_t i = 0; i < g->node_count; i++) {
if (!reached[i] || best_bg[i] <= 0.0) continue;
EngramNode* n = &g->nodes[i];
/* Per-type threshold: safety nodes break through more easily. */
double type_threshold = engram_type_threshold(n->node_type, n->tier);
/* Goal bias weights the node's relevance to current intent. */
double bias = engram_goal_bias(n, q);
/* Raw working memory score. */
double raw_wm = best_bg[i] * bias * n->confidence;
/* Apply inhibitory suppression. Full inhibition → scale by factor. */
double inh = inhibition[i];
if (inh > 1.0) inh = 1.0;
double suppress = 1.0 - (1.0 - ENGRAM_INHIBITION_FACTOR) * inh;
raw_wm *= suppress;
/* Threshold gate: must exceed per-type threshold to enter working
* memory. Type threshold replaces the old flat 0.2 filter. */
if (raw_wm >= type_threshold) {
wm_weights[i] = raw_wm > 1.0 ? 1.0 : raw_wm;
if (n->suppression_count > 0) n->suppression_count = 0;
} else {
/* Node didn't make it through — increment suppression counter.
* After N consecutive suppressions: force breakthrough. */
n->suppression_count++;
if (n->suppression_count >= ENGRAM_SUPPRESSION_BREAKTHROUGH) {
wm_weights[i] = ENGRAM_BREAKTHROUGH_WEIGHT;
n->suppression_count = 0;
} else {
wm_weights[i] = 0.0;
}
}
}
/* ── PASS 3: Layer 0 override (the sacred fire) ─────────────────────
* Every node in a non-suppressible layer that received any background
* activation is force-promoted to AT LEAST ENGRAM_LAYER0_OVERRIDE_WEIGHT.
* This runs LAST and overrides whatever Pass 2 decided — Layer 0 cannot
* be silenced by inhibitory edges, by goal-bias misalignment, by
* confidence weighting, or by per-type threshold gates. If the seed
* fan-out reached a structural-floor node, that node surfaces.
*
* Note: this also clears the suppression_count when an override fires,
* since the node DID surface this turn — it just took the override path
* rather than the standard threshold path. Without this, a Layer 0
* node with persistent inhibitory pressure would accumulate
* suppression_count forever and never reach the breakthrough state. */
for (int64_t i = 0; i < g->node_count; i++) {
if (!reached[i] || best_bg[i] <= 0.0) continue;
EngramNode* n = &g->nodes[i];
if (engram_layer_is_suppressible(n->layer_id)) continue;
if (wm_weights[i] < ENGRAM_LAYER0_OVERRIDE_WEIGHT) {
wm_weights[i] = ENGRAM_LAYER0_OVERRIDE_WEIGHT;
}
n->suppression_count = 0;
}
/* Persist working_memory_weight (post Pass 3) to node store. */
for (int64_t i = 0; i < g->node_count; i++) {
g->nodes[i].working_memory_weight = wm_weights[i];
}
/* ── Collect all background-activated nodes for the return value ────
* Callers see both layers. Context compilation uses only promoted nodes
* (working_memory_weight > 0). Sort: promoted first by wm_weight desc,
* then background-only by background_activation desc. */
typedef struct { int64_t idx; double bg; double wm; double epist; int64_t hops; } Result;
Result* results = malloc((size_t)g->node_count * sizeof(Result));
int64_t rcount = 0;
if (!results) {
free(best_bg); free(best_hops); free(reached); free(seeds);
free(fr); free(inhibition); free(wm_weights); return out;
}
for (int64_t i = 0; i < g->node_count; i++) {
if (!reached[i]) continue;
double epist = best_bg[i] * g->nodes[i].confidence;
/* Include if promoted to working memory OR if background activation
* is meaningful enough to report (epist >= 0.1). */
if (epist < 0.1 && wm_weights[i] <= 0.0) continue;
results[rcount].idx = i;
results[rcount].bg = best_bg[i];
results[rcount].wm = wm_weights[i];
results[rcount].epist = epist;
results[rcount].hops = best_hops[i];
rcount++;
}
/* Sort: promoted nodes first (by wm_weight desc), then background-only
* by background_activation desc. */
for (int64_t i = 1; i < rcount; i++) {
Result key = results[i];
int64_t j = i - 1;
while (j >= 0 && (results[j].wm < key.wm ||
(results[j].wm == key.wm && results[j].bg < key.bg))) {
results[j + 1] = results[j];
j--;
}
results[j + 1] = key;
}
for (int64_t i = 0; i < rcount; i++) {
el_val_t entry = el_map_new(0);
entry = el_map_set(entry, EL_STR(el_strdup("node")),
engram_node_to_map(&g->nodes[results[i].idx]));
entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
el_from_float(results[i].bg));
entry = el_map_set(entry, EL_STR(el_strdup("working_memory_weight")),
el_from_float(results[i].wm));
entry = el_map_set(entry, EL_STR(el_strdup("epistemic_confidence")),
el_from_float(results[i].epist));
entry = el_map_set(entry, EL_STR(el_strdup("hops")),
(el_val_t)results[i].hops);
entry = el_map_set(entry, EL_STR(el_strdup("promoted")),
(el_val_t)(results[i].wm > 0.0 ? 1 : 0));
out = el_list_append(out, entry);
}
free(best_bg); free(best_hops); free(reached);
free(seeds); free(fr); free(inhibition); free(wm_weights); free(results);
return out;
}
/* ── Engram persistence (JSON snapshot) ─────────────────────────────────── */
static void engram_emit_node_json(JsonBuf* b, const EngramNode* n) {
jb_putc(b, '{');
jb_puts(b, "\"id\":"); jb_emit_escaped(b, n->id ? n->id : "");
jb_puts(b, ",\"content\":"); jb_emit_escaped(b, n->content ? n->content : "");
jb_puts(b, ",\"node_type\":"); jb_emit_escaped(b, n->node_type ? n->node_type : "");
jb_puts(b, ",\"label\":"); jb_emit_escaped(b, n->label ? n->label : "");
jb_puts(b, ",\"tier\":"); jb_emit_escaped(b, n->tier ? n->tier : "Working");
jb_puts(b, ",\"tags\":"); jb_emit_escaped(b, n->tags ? n->tags : "");
jb_puts(b, ",\"metadata\":"); jb_emit_escaped(b, n->metadata ? n->metadata : "{}");
char tmp[80];
snprintf(tmp, sizeof(tmp), ",\"salience\":%g", n->salience); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"importance\":%g", n->importance); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"confidence\":%g", n->confidence); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"temporal_decay_rate\":%g", n->temporal_decay_rate); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"activation_count\":%lld", (long long)n->activation_count); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"last_activated\":%lld", (long long)n->last_activated); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld", (long long)n->created_at); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld", (long long)n->updated_at); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"background_activation\":%g", n->background_activation); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"working_memory_weight\":%g", n->working_memory_weight); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"suppression_count\":%d", n->suppression_count); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"layer_id\":%u", n->layer_id); jb_puts(b, tmp);
jb_putc(b, '}');
}
static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e) {
jb_putc(b, '{');
jb_puts(b, "\"id\":"); jb_emit_escaped(b, e->id ? e->id : "");
jb_puts(b, ",\"from_id\":"); jb_emit_escaped(b, e->from_id ? e->from_id : "");
jb_puts(b, ",\"to_id\":"); jb_emit_escaped(b, e->to_id ? e->to_id : "");
jb_puts(b, ",\"relation\":"); jb_emit_escaped(b, e->relation ? e->relation : "");
jb_puts(b, ",\"metadata\":"); jb_emit_escaped(b, e->metadata ? e->metadata : "{}");
char tmp[64];
snprintf(tmp, sizeof(tmp), ",\"weight\":%g", e->weight); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"confidence\":%g", e->confidence); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld", (long long)e->created_at); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld", (long long)e->updated_at); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"last_fired\":%lld", (long long)e->last_fired); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"inhibitory\":%d", e->inhibitory ? 1 : 0); jb_puts(b, tmp);
snprintf(tmp, sizeof(tmp), ",\"layer_id\":%u", e->layer_id); jb_puts(b, tmp);
jb_putc(b, '}');
}
el_val_t engram_save(el_val_t path) {
const char* p = EL_CSTR(path);
if (!p || !*p) return 0;
EngramStore* g = engram_get();
JsonBuf b; jb_init(&b);
jb_puts(&b, "{\"nodes\":[");
for (int64_t i = 0; i < g->node_count; i++) {
if (i > 0) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[i]);
}
jb_puts(&b, "],\"edges\":[");
for (int64_t i = 0; i < g->edge_count; i++) {
if (i > 0) jb_putc(&b, ',');
engram_emit_edge_json(&b, &g->edges[i]);
}
/* Layered consciousness — emit the layer registry under "layers".
* Older readers that don't know about this top-level key will simply
* ignore it (forward compatible). Tombstoned (removed-injectable)
* layers are skipped — they have no name and can't be re-created
* meaningfully on load anyway. */
jb_puts(&b, "],\"layers\":[");
int first_layer = 1;
for (size_t i = 0; i < g->layer_count; i++) {
EngramLayer* L = &g->layers[i];
if (!L->name) continue;
if (!first_layer) jb_putc(&b, ',');
first_layer = 0;
jb_putc(&b, '{');
char tmp[80];
snprintf(tmp, sizeof(tmp), "\"layer_id\":%u", L->layer_id);
jb_puts(&b, tmp);
jb_puts(&b, ",\"name\":");
jb_emit_escaped(&b, L->name);
snprintf(tmp, sizeof(tmp), ",\"activation_priority\":%u", L->activation_priority);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"suppressible\":%d", L->suppressible ? 1 : 0);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"transparent\":%d", L->transparent ? 1 : 0);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"injectable\":%d", L->injectable ? 1 : 0);
jb_puts(&b, tmp);
jb_putc(&b, '}');
}
jb_puts(&b, "]}");
FILE* f = fopen(p, "wb");
if (!f) { free(b.buf); return 0; }
size_t w = fwrite(b.buf, 1, b.len, f);
fclose(f);
int ok = (w == b.len);
free(b.buf);
return ok ? 1 : 0;
}
/* Helper: extract a string field from a JSON object substring. */
static char* eg_get_str_field(const char* obj, const char* key) {
const char* p = json_find_key(obj, key);
if (!p) return el_strdup("");
if (*p != '"') return el_strdup("");
JsonParser jp = { .p = p, .end = p + strlen(p), .err = 0 };
char* out = jp_parse_string_raw(&jp);
if (jp.err) { free(out); return el_strdup(""); }
return out;
}
static double eg_get_num_field(const char* obj, const char* key) {
const char* p = json_find_key(obj, key);
if (!p || *p == '"' || *p == '{' || *p == '[') return 0.0;
return strtod(p, NULL);
}
static int64_t eg_get_int_field(const char* obj, const char* key) {
const char* p = json_find_key(obj, key);
if (!p || *p == '"' || *p == '{' || *p == '[') return 0;
return strtoll(p, NULL, 10);
}
/* Iterate the top-level nodes/edges arrays in a saved snapshot. */
static const char* eg_skip_ws(const char* p) {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
return p;
}
el_val_t engram_load(el_val_t path) {
const char* p = EL_CSTR(path);
if (!p || !*p) return 0;
FILE* f = fopen(p, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0) { fclose(f); return 0; }
char* data = malloc((size_t)sz + 1);
if (!data) { fclose(f); return 0; }
size_t got = fread(data, 1, (size_t)sz, f);
fclose(f);
data[got] = '\0';
/* Reset store */
EngramStore* g = engram_get();
for (int64_t i = 0; i < g->node_count; i++) {
free(g->nodes[i].id); free(g->nodes[i].content); free(g->nodes[i].node_type);
free(g->nodes[i].label); free(g->nodes[i].tier); free(g->nodes[i].tags);
free(g->nodes[i].metadata);
}
g->node_count = 0;
for (int64_t i = 0; i < g->edge_count; i++) {
free(g->edges[i].id); free(g->edges[i].from_id); free(g->edges[i].to_id);
free(g->edges[i].relation); free(g->edges[i].metadata);
}
g->edge_count = 0;
/* Walk nodes array */
const char* nodes_p = json_find_key(data, "nodes");
if (nodes_p) {
nodes_p = eg_skip_ws(nodes_p);
if (*nodes_p == '[') {
nodes_p++;
nodes_p = eg_skip_ws(nodes_p);
while (*nodes_p && *nodes_p != ']') {
if (*nodes_p != '{') { nodes_p++; continue; }
const char* end = json_skip_value(nodes_p);
size_t n = (size_t)(end - nodes_p);
char* obj = malloc(n + 1);
memcpy(obj, nodes_p, n); obj[n] = '\0';
engram_grow_nodes();
EngramNode* nn = &g->nodes[g->node_count];
memset(nn, 0, sizeof(*nn));
nn->id = eg_get_str_field(obj, "id");
nn->content = eg_get_str_field(obj, "content");
nn->node_type = eg_get_str_field(obj, "node_type");
nn->label = eg_get_str_field(obj, "label");
nn->tier = eg_get_str_field(obj, "tier");
nn->tags = eg_get_str_field(obj, "tags");
nn->metadata = eg_get_str_field(obj, "metadata");
if (!nn->metadata || !*nn->metadata) { free(nn->metadata); nn->metadata = el_strdup("{}"); }
nn->salience = eg_get_num_field(obj, "salience");
nn->importance = eg_get_num_field(obj, "importance");
nn->confidence = eg_get_num_field(obj, "confidence");
nn->temporal_decay_rate = eg_get_num_field(obj, "temporal_decay_rate");
/* temporal_decay_rate defaults to 0 (use global) if absent in snapshot */
nn->activation_count = eg_get_int_field(obj, "activation_count");
nn->last_activated = eg_get_int_field(obj, "last_activated");
nn->created_at = eg_get_int_field(obj, "created_at");
nn->updated_at = eg_get_int_field(obj, "updated_at");
nn->background_activation = eg_get_num_field(obj, "background_activation");
nn->working_memory_weight = eg_get_num_field(obj, "working_memory_weight");
nn->suppression_count = (int32_t)eg_get_int_field(obj, "suppression_count");
/* layer_id defaults to ENGRAM_LAYER_DEFAULT (core-identity)
* for snapshots that predate the layered schema. We can't
* tell "explicit 0" from "missing field" using the helper
* directly, so probe for the key — if absent, fall back. */
if (json_find_key(obj, "layer_id")) {
nn->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
} else {
nn->layer_id = ENGRAM_LAYER_DEFAULT;
}
g->node_count++;
free(obj);
nodes_p = end;
nodes_p = eg_skip_ws(nodes_p);
if (*nodes_p == ',') { nodes_p++; nodes_p = eg_skip_ws(nodes_p); }
}
}
}
/* Walk edges array */
const char* edges_p = json_find_key(data, "edges");
if (edges_p) {
edges_p = eg_skip_ws(edges_p);
if (*edges_p == '[') {
edges_p++;
edges_p = eg_skip_ws(edges_p);
while (*edges_p && *edges_p != ']') {
if (*edges_p != '{') { edges_p++; continue; }
const char* end = json_skip_value(edges_p);
size_t n = (size_t)(end - edges_p);
char* obj = malloc(n + 1);
memcpy(obj, edges_p, n); obj[n] = '\0';
engram_grow_edges();
EngramEdge* ee = &g->edges[g->edge_count];
memset(ee, 0, sizeof(*ee));
ee->id = eg_get_str_field(obj, "id");
ee->from_id = eg_get_str_field(obj, "from_id");
ee->to_id = eg_get_str_field(obj, "to_id");
ee->relation = eg_get_str_field(obj, "relation");
ee->metadata = eg_get_str_field(obj, "metadata");
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup("{}"); }
ee->weight = eg_get_num_field(obj, "weight");
ee->confidence = eg_get_num_field(obj, "confidence");
ee->created_at = eg_get_int_field(obj, "created_at");
ee->updated_at = eg_get_int_field(obj, "updated_at");
ee->last_fired = eg_get_int_field(obj, "last_fired");
ee->inhibitory = (int)eg_get_int_field(obj, "inhibitory");
if (json_find_key(obj, "layer_id")) {
ee->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
} else {
ee->layer_id = ENGRAM_LAYER_DEFAULT;
}
g->edge_count++;
free(obj);
edges_p = end;
edges_p = eg_skip_ws(edges_p);
if (*edges_p == ',') { edges_p++; edges_p = eg_skip_ws(edges_p); }
}
}
}
/* Walk layers array (optional — older snapshots omit this).
* If present we replace the canonical registry entirely; if absent we
* keep whatever the engram_get() init established. */
const char* layers_p = json_find_key(data, "layers");
if (layers_p) {
layers_p = eg_skip_ws(layers_p);
if (*layers_p == '[') {
/* Reset existing layer registry. Free strdup'd names; the
* struct array itself can be reused. */
for (size_t i = 0; i < g->layer_count; i++) {
if (g->layers[i].name) free(g->layers[i].name);
g->layers[i].name = NULL;
}
g->layer_count = 0;
layers_p++;
layers_p = eg_skip_ws(layers_p);
while (*layers_p && *layers_p != ']') {
if (*layers_p != '{') { layers_p++; continue; }
const char* end = json_skip_value(layers_p);
size_t n = (size_t)(end - layers_p);
char* obj = malloc(n + 1);
memcpy(obj, layers_p, n); obj[n] = '\0';
if (g->layer_count >= g->layer_capacity) {
size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
EngramLayer* grown = realloc(g->layers, nc * sizeof(EngramLayer));
if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
memset(grown + g->layer_capacity, 0,
(nc - g->layer_capacity) * sizeof(EngramLayer));
g->layers = grown;
g->layer_capacity = nc;
}
EngramLayer* L = &g->layers[g->layer_count];
memset(L, 0, sizeof(*L));
L->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
L->activation_priority = (uint32_t)eg_get_int_field(obj, "activation_priority");
L->suppressible = (int)eg_get_int_field(obj, "suppressible") ? 1 : 0;
L->transparent = (int)eg_get_int_field(obj, "transparent") ? 1 : 0;
L->injectable = (int)eg_get_int_field(obj, "injectable") ? 1 : 0;
char* nm = eg_get_str_field(obj, "name");
if (nm && *nm) {
L->name = el_strdup_persist(nm);
free(nm);
} else {
free(nm);
L->name = el_strdup_persist("");
}
g->layer_count++;
free(obj);
layers_p = end;
layers_p = eg_skip_ws(layers_p);
if (*layers_p == ',') { layers_p++; layers_p = eg_skip_ws(layers_p); }
}
}
}
free(data);
return 1;
}
/* ── Engram JSON-string accessors ─────────────────────────────────────────
* These return pre-serialized JSON strings so callers (especially HTTP
* handlers) don't have to round-trip ElList/ElMap through json_stringify
* — which can't reliably distinguish those structures from raw pointers
* due to el_val_t's type erasure. The runtime knows the real C types and
* can serialize directly. */
el_val_t engram_get_node_json(el_val_t id) {
const char* sid = EL_CSTR(id);
EngramNode* n = engram_find_node(sid);
if (!n) return el_wrap_str(el_strdup("{}"));
JsonBuf b; jb_init(&b);
engram_emit_node_json(&b, n);
return el_wrap_str(b.buf);
}
el_val_t engram_search_json(el_val_t query, el_val_t limit) {
EngramStore* g = engram_get();
const char* q = EL_CSTR(query);
int64_t lim = (int64_t)limit;
if (lim <= 0) lim = 100;
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
int first = 1;
int64_t found = 0;
if (q && *q) {
for (int64_t i = 0; i < g->node_count && found < lim; i++) {
EngramNode* n = &g->nodes[i];
/* Filter transparent layers — same as engram_search. */
if (engram_layer_is_transparent(n->layer_id)) continue;
if (istr_contains(n->content, q) ||
istr_contains(n->label, q) ||
istr_contains(n->tags, q)) {
if (!first) jb_putc(&b, ',');
engram_emit_node_json(&b, n);
first = 0;
found++;
}
}
}
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
EngramStore* g = engram_get();
int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
int64_t off = (int64_t)offset; if (off < 0) off = 0;
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
/* Skip transparent layers — introspection filter, same as engram_scan_nodes. */
int64_t live = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
idx[live++] = i;
}
engram_sort_indices_by_salience(idx, live, g->nodes);
int64_t end = off + lim;
if (end > live) end = live;
int first = 1;
for (int64_t i = off; i < end; i++) {
if (!first) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[idx[i]]);
first = 0;
}
free(idx);
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
/* engram_scan_nodes_by_type_json — filter by node_type before paginating.
* Empty / NULL type_v falls back to the unfiltered scan (existing behaviour).
* Result is JSON array, salience-sorted, transparent layers skipped. */
el_val_t engram_scan_nodes_by_type_json(el_val_t type_v, el_val_t limit, el_val_t offset) {
const char* type_filter = EL_CSTR(type_v);
if (!type_filter || !*type_filter) {
return engram_scan_nodes_json(limit, offset);
}
EngramStore* g = engram_get();
int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
int64_t off = (int64_t)offset; if (off < 0) off = 0;
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
int64_t live = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
const char* nt = g->nodes[i].node_type;
if (!nt || strcmp(nt, type_filter) != 0) continue;
idx[live++] = i;
}
engram_sort_indices_by_salience(idx, live, g->nodes);
int64_t end = off + lim;
if (end > live) end = live;
int first = 1;
for (int64_t i = off; i < end; i++) {
if (!first) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[idx[i]]);
first = 0;
}
free(idx);
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
/* Re-implement here directly so we serialize without going through
* the ElList path. Walks BFS to max_depth, emits {node, edge, hops}
* triples. */
EngramStore* g = engram_get();
const char* sid = EL_CSTR(node_id);
int64_t depth = (int64_t)max_depth; if (depth <= 0) depth = 1;
const char* dir = EL_CSTR(direction); if (!dir) dir = "both";
int allow_out = (strcmp(dir, "out") == 0) || (strcmp(dir, "both") == 0);
int allow_in = (strcmp(dir, "in") == 0) || (strcmp(dir, "both") == 0);
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
if (!sid || !*sid) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
/* Frontier of (node_id, hops). Cap to a sane size. */
char** frontier = calloc(1024, sizeof(char*));
int64_t* frontier_h = calloc(1024, sizeof(int64_t));
int64_t fc = 0;
char** visited = calloc(1024, sizeof(char*));
int64_t vc = 0;
if (!frontier || !frontier_h || !visited) {
free(frontier); free(frontier_h); free(visited);
jb_putc(&b, ']'); return el_wrap_str(b.buf);
}
/* Use plain strdup (not el_strdup) so arena doesn't track these pointers.
* The BFS loop manually frees them below — arena would double-free them. */
frontier[fc] = strdup(sid); frontier_h[fc] = 0; fc++;
visited[vc++] = strdup(sid);
int first = 1;
while (fc > 0) {
char* cur = frontier[0]; int64_t h = frontier_h[0];
for (int64_t k = 1; k < fc; k++) { frontier[k-1] = frontier[k]; frontier_h[k-1] = frontier_h[k]; }
fc--;
if (h >= depth) { free(cur); continue; }
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
const char* peer = NULL;
if (allow_out && e->from_id && strcmp(e->from_id, cur) == 0) peer = e->to_id;
else if (allow_in && e->to_id && strcmp(e->to_id, cur) == 0) peer = e->from_id;
if (!peer) continue;
int seen = 0;
for (int64_t v = 0; v < vc; v++) {
if (strcmp(visited[v], peer) == 0) { seen = 1; break; }
}
if (seen) continue;
EngramNode* n = engram_find_node(peer);
if (!n) continue;
if (!first) jb_putc(&b, ',');
jb_puts(&b, "{\"node\":");
engram_emit_node_json(&b, n);
jb_puts(&b, ",\"edge\":");
engram_emit_edge_json(&b, e);
char tmp[64]; snprintf(tmp, sizeof(tmp), ",\"hops\":%lld}", (long long)(h + 1));
jb_puts(&b, tmp);
first = 0;
if (vc < 1024) visited[vc++] = strdup(peer);
if (fc < 1024 && h + 1 < depth) { frontier[fc] = strdup(peer); frontier_h[fc] = h + 1; fc++; }
}
free(cur);
}
for (int64_t i = 0; i < fc; i++) free(frontier[i]);
for (int64_t i = 0; i < vc; i++) free(visited[i]);
free(frontier); free(frontier_h); free(visited);
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
el_val_t engram_activate_json(el_val_t query, el_val_t depth) {
/* Run two-layer engram_activate and serialize the result list to JSON.
* Each entry includes both activation_strength (layer 1 background) and
* working_memory_weight (layer 2 executive filter), plus promoted flag.
* Callers performing context compilation should filter to promoted=1. */
el_val_t lst = engram_activate(query, depth);
ElList* arr = (ElList*)(uintptr_t)lst;
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
if (arr) {
for (int64_t i = 0; i < arr->length; i++) {
if (!arr->elems[i]) continue;
el_val_t node_map = el_map_get(arr->elems[i], EL_STR("node"));
el_val_t strength_v = el_map_get(arr->elems[i], EL_STR("activation_strength"));
el_val_t wm_v = el_map_get(arr->elems[i], EL_STR("working_memory_weight"));
el_val_t epist_v = el_map_get(arr->elems[i], EL_STR("epistemic_confidence"));
el_val_t hops_v = el_map_get(arr->elems[i], EL_STR("hops"));
el_val_t promoted_v = el_map_get(arr->elems[i], EL_STR("promoted"));
/* Look up underlying EngramNode by id to emit canonical JSON. */
el_val_t id_v = el_map_get(node_map, EL_STR("id"));
const char* id_s = EL_CSTR(id_v);
EngramNode* n = id_s ? engram_find_node(id_s) : NULL;
if (i > 0) jb_putc(&b, ',');
jb_puts(&b, "{\"node\":");
if (n) {
engram_emit_node_json(&b, n);
} else {
jb_puts(&b, "{}");
}
char tmp[80];
snprintf(tmp, sizeof(tmp), ",\"activation_strength\":%g", el_to_float(strength_v)); jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"working_memory_weight\":%g", el_to_float(wm_v)); jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"epistemic_confidence\":%g", el_to_float(epist_v)); jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"hops\":%lld", (long long)(int64_t)hops_v); jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"promoted\":%d}", (int)(int64_t)promoted_v); jb_puts(&b, tmp);
}
}
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
el_val_t engram_stats_json(void) {
EngramStore* g = engram_get();
char buf[128];
snprintf(buf, sizeof(buf),
"{\"node_count\":%lld,\"edge_count\":%lld,\"layer_count\":%zu}",
(long long)g->node_count, (long long)g->edge_count, g->layer_count);
return el_wrap_str(el_strdup(buf));
}
/* engram_list_layers_json — serialized counterpart of engram_list_layers.
* Returns a JSON array, sorted by activation_priority ascending. */
el_val_t engram_list_layers_json(void) {
EngramStore* g = engram_get();
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
/* Build a sorted index over live layers. */
size_t* idx = malloc((g->layer_count + 1) * sizeof(size_t));
if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
size_t live = 0;
for (size_t i = 0; i < g->layer_count; i++) {
if (g->layers[i].name) idx[live++] = i;
}
for (size_t i = 1; i < live; i++) {
size_t key = idx[i];
uint32_t kp = g->layers[key].activation_priority;
size_t j = i;
while (j > 0 && g->layers[idx[j - 1]].activation_priority > kp) {
idx[j] = idx[j - 1];
j--;
}
idx[j] = key;
}
int first = 1;
for (size_t i = 0; i < live; i++) {
EngramLayer* L = &g->layers[idx[i]];
if (!first) jb_putc(&b, ',');
first = 0;
jb_putc(&b, '{');
char tmp[80];
snprintf(tmp, sizeof(tmp), "\"layer_id\":%u", L->layer_id); jb_puts(&b, tmp);
jb_puts(&b, ",\"name\":");
jb_emit_escaped(&b, L->name ? L->name : "");
snprintf(tmp, sizeof(tmp), ",\"activation_priority\":%u", L->activation_priority);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"suppressible\":%d", L->suppressible ? 1 : 0);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"transparent\":%d", L->transparent ? 1 : 0);
jb_puts(&b, tmp);
snprintf(tmp, sizeof(tmp), ",\"injectable\":%d", L->injectable ? 1 : 0);
jb_puts(&b, tmp);
jb_putc(&b, '}');
}
free(idx);
jb_putc(&b, ']');
return el_wrap_str(b.buf);
}
/* engram_compile_layered_json — produce a prompt-ready context block split
* by layer.
*
* Runs the three-pass activation, then partitions promoted nodes by layer
* suppressibility:
* - Non-suppressible (Layer 0 / structural-floor) layers go FIRST under
* the heading "[LAYER 0 — STRUCTURAL]". These are the sacred-fire
* nodes that surfaced via the pass-3 override.
* - All other promoted layers go SECOND under "[ENGRAM CONTEXT]".
*
* Output is a single JSON-string el_val_t: a UTF-8 text block ready to be
* concatenated into a system prompt. Returns "" if no nodes promoted.
*
* Transparent layers (Layer 0) are emitted into the prompt — they shape
* the model's output — but engram_search and friends still hide them from
* introspection-style queries. The split heading lets the LLM weight them
* appropriately without revealing their internal label.
*
* Each emitted line for a node is its raw JSON (matching engram_emit_node_json)
* so downstream JSON parsers can still walk individual records inside the
* formatted block. The block is plain text, not a JSON document — callers
* concatenating it into a prompt should treat it as opaque markdown. */
el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth) {
EngramStore* g = engram_get();
/* Run the three-pass activator. We need the persisted node fields, so
* call engram_activate (it writes background_activation and
* working_memory_weight back into the store). */
(void)engram_activate(intent, depth);
/* Walk the store and partition by suppressibility. */
JsonBuf b; jb_init(&b);
int wrote_layer0 = 0;
int wrote_normal = 0;
/* Sort indices by working_memory_weight descending so the most
* confidently promoted nodes appear first within each section. */
int64_t* idx = malloc((size_t)(g->node_count + 1) * sizeof(int64_t));
if (!idx) return el_wrap_str(el_strdup(""));
int64_t mc = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].working_memory_weight > 0.0) idx[mc++] = i;
}
for (int64_t i = 1; i < mc; i++) {
int64_t key = idx[i];
double kw = g->nodes[key].working_memory_weight;
int64_t j = i;
while (j > 0 && g->nodes[idx[j - 1]].working_memory_weight < kw) {
idx[j] = idx[j - 1];
j--;
}
idx[j] = key;
}
/* Section 1: structural floor (non-suppressible layers). */
for (int64_t i = 0; i < mc; i++) {
EngramNode* n = &g->nodes[idx[i]];
if (engram_layer_is_suppressible(n->layer_id)) continue;
if (!wrote_layer0) {
jb_puts(&b, "[LAYER 0 — STRUCTURAL]\n");
wrote_layer0 = 1;
}
engram_emit_node_json(&b, n);
jb_putc(&b, '\n');
}
/* Section 2: standard engram context (suppressible layers). */
for (int64_t i = 0; i < mc; i++) {
EngramNode* n = &g->nodes[idx[i]];
if (!engram_layer_is_suppressible(n->layer_id)) continue;
if (!wrote_normal) {
if (wrote_layer0) jb_putc(&b, '\n');
jb_puts(&b, "[ENGRAM CONTEXT]\n");
wrote_normal = 1;
}
engram_emit_node_json(&b, n);
jb_putc(&b, '\n');
}
free(idx);
if (b.len == 0) {
free(b.buf);
return el_wrap_str(el_strdup(""));
}
return el_wrap_str(b.buf);
}
/* engram_query_range — temporal range query.
* Returns a JSON array of nodes whose created_at OR last_activated falls
* within [start_ms, end_ms], sorted by created_at ascending.
* Enables "what was I working on last Tuesday?" style queries by passing
* unix-millisecond timestamps for the start and end of the target interval.
* Both endpoints are inclusive. Pass 0 for start_ms to mean "beginning of
* time"; pass 0 for end_ms to mean "now". */
el_val_t engram_query_range(el_val_t start_ms_v, el_val_t end_ms_v) {
EngramStore* g = engram_get();
int64_t start_ms = (int64_t)start_ms_v;
int64_t end_ms = (int64_t)end_ms_v;
if (end_ms <= 0) end_ms = engram_now_ms();
/* Collect matching indices. */
int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
if (!idx) return el_wrap_str(el_strdup("[]"));
int64_t mc = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
int in_created = (n->created_at >= start_ms && n->created_at <= end_ms);
int in_activated = (n->last_activated >= start_ms && n->last_activated <= end_ms);
if (in_created || in_activated) idx[mc++] = i;
}
/* Sort by created_at ascending (insertion sort — N is small in practice). */
for (int64_t i = 1; i < mc; i++) {
int64_t key = idx[i];
int64_t kts = g->nodes[key].created_at;
int64_t j = i - 1;
while (j >= 0 && g->nodes[idx[j]].created_at > kts) {
idx[j + 1] = idx[j];
j--;
}
idx[j + 1] = key;
}
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
for (int64_t i = 0; i < mc; i++) {
if (i > 0) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[idx[i]]);
}
jb_putc(&b, ']');
free(idx);
return el_wrap_str(b.buf);
}
#ifdef HAVE_CURL
/* ── DHARMA network ─────────────────────────────────────────────────────────
* Real implementation. Peers are addressed by `dharma_id` — either bare
* (e.g. "ntn-genesis", transport defaults to http://localhost:7770) or
* "<id>@<url>" where <url> is the peer's Engram-exposed daemon.
*
* Channels are logical handles cached per-cgi: `dharma_connect` is
* idempotent and returns "ch:<cgi_id>". The channel registry below tracks
* every cgi_id we've connected to and its resolved transport URL.
*
* Relationship weights live in the local Engram graph: edges of type
* "dharma-relation" between a synthetic local node ("dharma:self") and
* synthetic peer nodes ("dharma:peer:<cgi_id>"). Hebbian increments
* accumulate in EngramEdge.weight, clamped to [0.0, 1.0].
*
* Events arrive over HTTP via the application's request handler, which is
* expected to call el_runtime_dharma_event_arrive() when it sees a
* /dharma/event POST. dharma_field() blocks on a per-event-type queue.
*/
#define DHARMA_DEFAULT_URL "http://localhost:7770"
/* Channel registry — one entry per known peer. */
typedef struct DharmaChannel {
char* cgi_id; /* full dharma_id including any @<url> suffix */
char* base_id; /* registry-id portion (before @) for relationship lookup */
char* url; /* resolved transport URL */
char* channel_id; /* "ch:<cgi_id>" */
} DharmaChannel;
static DharmaChannel* _dharma_channels = NULL;
static size_t _dharma_channel_count = 0;
static size_t _dharma_channel_cap = 0;
static pthread_mutex_t _dharma_channel_mu = PTHREAD_MUTEX_INITIALIZER;
/* Event queue — per-type linked list. dharma_field blocks on _dharma_event_cv. */
typedef struct DharmaEvent {
char* event_type;
char* payload;
char* source;
int64_t timestamp;
struct DharmaEvent* next;
} DharmaEvent;
static DharmaEvent* _dharma_event_head = NULL;
static DharmaEvent* _dharma_event_tail = NULL;
static pthread_mutex_t _dharma_event_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t _dharma_event_cv = PTHREAD_COND_INITIALIZER;
/* Split "<id>@<url>" → (base_id, url). If no "@", base_id = full, url = default.
* Returned strings are heap-allocated; caller must free. */
static void dharma_parse_id(const char* full, char** out_base, char** out_url) {
if (!full) full = "";
const char* at = strchr(full, '@');
if (at) {
size_t bn = (size_t)(at - full);
char* b = malloc(bn + 1);
memcpy(b, full, bn); b[bn] = '\0';
*out_base = b;
*out_url = el_strdup(at + 1);
if (!**out_url) { free(*out_url); *out_url = el_strdup(DHARMA_DEFAULT_URL); }
} else {
*out_base = el_strdup(full);
*out_url = el_strdup(DHARMA_DEFAULT_URL);
}
}
/* Find existing channel by full cgi_id. Caller must hold _dharma_channel_mu. */
static DharmaChannel* dharma_find_channel_locked(const char* cgi_id) {
if (!cgi_id) return NULL;
for (size_t i = 0; i < _dharma_channel_count; i++) {
if (_dharma_channels[i].cgi_id &&
strcmp(_dharma_channels[i].cgi_id, cgi_id) == 0) {
return &_dharma_channels[i];
}
}
return NULL;
}
/* Add a new channel entry. Caller must hold _dharma_channel_mu. */
static DharmaChannel* dharma_add_channel_locked(const char* cgi_id) {
if (_dharma_channel_count >= _dharma_channel_cap) {
size_t nc = _dharma_channel_cap ? _dharma_channel_cap * 2 : 8;
_dharma_channels = realloc(_dharma_channels, nc * sizeof(DharmaChannel));
if (!_dharma_channels) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
memset(_dharma_channels + _dharma_channel_cap, 0,
(nc - _dharma_channel_cap) * sizeof(DharmaChannel));
_dharma_channel_cap = nc;
}
DharmaChannel* ch = &_dharma_channels[_dharma_channel_count++];
char* base = NULL; char* url = NULL;
dharma_parse_id(cgi_id, &base, &url);
ch->cgi_id = el_strdup(cgi_id ? cgi_id : "");
ch->base_id = base;
ch->url = url;
size_t cn = strlen(ch->cgi_id) + 4;
ch->channel_id = malloc(cn);
snprintf(ch->channel_id, cn, "ch:%s", ch->cgi_id);
return ch;
}
el_val_t dharma_connect(el_val_t cgi_id) {
const char* id = EL_CSTR(cgi_id);
if (!id || !*id) return el_wrap_str(el_strdup(""));
pthread_mutex_lock(&_dharma_channel_mu);
DharmaChannel* ch = dharma_find_channel_locked(id);
if (!ch) ch = dharma_add_channel_locked(id);
char* out = el_strdup(ch->channel_id);
pthread_mutex_unlock(&_dharma_channel_mu);
return el_wrap_str(out);
}
/* Build an error JSON body — same shape http_error_json uses. */
static el_val_t dharma_error_json(const char* msg) {
return http_error_json(msg);
}
el_val_t dharma_send(el_val_t channel, el_val_t content) {
const char* ch_id = EL_CSTR(channel);
const char* msg = EL_CSTR(content);
if (!ch_id || strncmp(ch_id, "ch:", 3) != 0) {
return dharma_error_json("invalid channel");
}
const char* peer_id = ch_id + 3;
/* Look up channel; if unknown (caller fabricated), auto-register. */
pthread_mutex_lock(&_dharma_channel_mu);
DharmaChannel* ch = dharma_find_channel_locked(peer_id);
if (!ch) ch = dharma_add_channel_locked(peer_id);
char* url = el_strdup(ch->url);
pthread_mutex_unlock(&_dharma_channel_mu);
/* Build /dharma/recv body. */
const char* from = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
char* esc_ch = json_escape_alloc(ch_id);
char* esc_from = json_escape_alloc(from);
char* esc_msg = json_escape_alloc(msg ? msg : "");
JsonBuf b; jb_init(&b);
jb_puts(&b, "{\"channel\":\""); jb_puts(&b, esc_ch);
jb_puts(&b, "\",\"from\":\""); jb_puts(&b, esc_from);
jb_puts(&b, "\",\"content\":\""); jb_puts(&b, esc_msg);
jb_puts(&b, "\"}");
free(esc_ch); free(esc_from); free(esc_msg);
size_t ul = strlen(url) + 16;
char* full_url = malloc(ul);
snprintf(full_url, ul, "%s/dharma/recv", url);
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
el_val_t resp = http_do("POST", full_url, b.buf, h);
curl_slist_free_all(h);
free(b.buf); free(full_url); free(url);
return resp;
}
el_val_t dharma_activate(el_val_t query) {
const char* q = EL_CSTR(query);
if (!q) q = "";
el_val_t out = el_list_empty();
char* esc_q = json_escape_alloc(q);
JsonBuf body; jb_init(&body);
jb_puts(&body, "{\"query\":\""); jb_puts(&body, esc_q); jb_puts(&body, "\"}");
free(esc_q);
/* Snapshot the channel list under lock so we can iterate without
* holding the mutex during network I/O. */
pthread_mutex_lock(&_dharma_channel_mu);
size_t n = _dharma_channel_count;
char** urls = calloc(n ? n : 1, sizeof(char*));
char** ids = calloc(n ? n : 1, sizeof(char*));
char** bases = calloc(n ? n : 1, sizeof(char*));
for (size_t i = 0; i < n; i++) {
urls[i] = el_strdup(_dharma_channels[i].url);
ids[i] = el_strdup(_dharma_channels[i].cgi_id);
bases[i] = el_strdup(_dharma_channels[i].base_id);
}
pthread_mutex_unlock(&_dharma_channel_mu);
for (size_t i = 0; i < n; i++) {
size_t ul = strlen(urls[i]) + 32;
char* full_url = malloc(ul);
snprintf(full_url, ul, "%s/api/activate", urls[i]);
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
el_val_t resp = http_do("POST", full_url, body.buf, h);
curl_slist_free_all(h);
free(full_url);
const char* rs = EL_CSTR(resp);
if (!rs || !*rs) continue;
if (rs[0] == '{' && strstr(rs, "\"error\"")) continue;
/* Look up relationship weight (attenuation). */
double rel_weight = 1.0;
{
const char* self_id = "dharma:self";
char peer_node[512];
snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", bases[i]);
EngramStore* g = engram_get();
for (int64_t k = 0; k < g->edge_count; k++) {
EngramEdge* e = &g->edges[k];
if (e->from_id && e->to_id &&
strcmp(e->from_id, self_id) == 0 &&
strcmp(e->to_id, peer_node) == 0 &&
e->relation && strcmp(e->relation, "dharma-relation") == 0) {
rel_weight = e->weight;
break;
}
}
}
/* Iterate the response array. Expect either a top-level array
* or an object whose "results" field is an array. */
const char* arr = rs;
while (*arr == ' ' || *arr == '\t' || *arr == '\n' || *arr == '\r') arr++;
char* arr_owned = NULL;
if (*arr == '{') {
el_val_t r = json_get_raw(EL_STR(rs), EL_STR("results"));
const char* rr = EL_CSTR(r);
if (rr && *rr == '[') {
arr_owned = el_strdup(rr);
arr = arr_owned;
} else {
continue;
}
}
if (*arr != '[') { free(arr_owned); continue; }
const char* p = arr + 1;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
while (*p && *p != ']') {
const char* end = json_skip_value(p);
size_t en = (size_t)(end - p);
char* obj = el_strbuf(en);
memcpy(obj, p, en); obj[en] = '\0';
/* Pull activation_strength if present, else 1.0. */
el_val_t act_v = json_get_float(EL_STR(obj), EL_STR("activation_strength"));
double act = el_to_float(act_v);
if (!(act > 0.0 && act <= 100.0)) act = 1.0;
double final_act = act * rel_weight;
el_val_t entry = el_map_new(0);
/* node = the inner JSON if present, else the entire obj. */
el_val_t node_raw = json_get_raw(EL_STR(obj), EL_STR("node"));
const char* nr = EL_CSTR(node_raw);
entry = el_map_set(entry, EL_STR(el_strdup("node")),
(nr && *nr) ? node_raw : EL_STR(el_strdup(obj)));
entry = el_map_set(entry, EL_STR(el_strdup("source_cgi")),
EL_STR(el_strdup(ids[i])));
entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
el_from_float(final_act));
out = el_list_append(out, entry);
free(obj);
p = end;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
}
free(arr_owned);
}
for (size_t i = 0; i < n; i++) { free(urls[i]); free(ids[i]); free(bases[i]); }
free(urls); free(ids); free(bases);
free(body.buf);
return out;
}
void dharma_emit(el_val_t event_type, el_val_t payload) {
const char* et = EL_CSTR(event_type);
const char* pay = EL_CSTR(payload);
if (!et) et = "";
if (!pay) pay = "";
const char* src = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
int64_t ts = engram_now_ms();
char* esc_et = json_escape_alloc(et);
char* esc_pay = json_escape_alloc(pay);
char* esc_src = json_escape_alloc(src);
JsonBuf b; jb_init(&b);
jb_puts(&b, "{\"type\":\""); jb_puts(&b, esc_et);
jb_puts(&b, "\",\"payload\":\""); jb_puts(&b, esc_pay);
jb_puts(&b, "\",\"source\":\""); jb_puts(&b, esc_src);
jb_puts(&b, "\",\"timestamp\":"); jb_emit_int(&b, ts);
jb_putc(&b, '}');
free(esc_et); free(esc_pay); free(esc_src);
/* Snapshot URLs to avoid holding the channel mutex during I/O. */
pthread_mutex_lock(&_dharma_channel_mu);
size_t n = _dharma_channel_count;
char** urls = calloc(n ? n : 1, sizeof(char*));
for (size_t i = 0; i < n; i++) urls[i] = el_strdup(_dharma_channels[i].url);
pthread_mutex_unlock(&_dharma_channel_mu);
for (size_t i = 0; i < n; i++) {
size_t ul = strlen(urls[i]) + 32;
char* full_url = malloc(ul);
snprintf(full_url, ul, "%s/dharma/event", urls[i]);
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
el_val_t r = http_do("POST", full_url, b.buf, h);
(void)r; /* fire-and-forget — emit is not synchronous */
curl_slist_free_all(h);
free(full_url);
}
for (size_t i = 0; i < n; i++) free(urls[i]);
free(urls);
free(b.buf);
}
void el_runtime_dharma_event_arrive(const char* event_type, const char* payload,
const char* source) {
DharmaEvent* ev = calloc(1, sizeof(DharmaEvent));
if (!ev) return;
ev->event_type = el_strdup(event_type ? event_type : "");
ev->payload = el_strdup(payload ? payload : "");
ev->source = el_strdup(source ? source : "");
ev->timestamp = engram_now_ms();
ev->next = NULL;
pthread_mutex_lock(&_dharma_event_mu);
if (_dharma_event_tail) _dharma_event_tail->next = ev;
else _dharma_event_head = ev;
_dharma_event_tail = ev;
pthread_cond_broadcast(&_dharma_event_cv);
pthread_mutex_unlock(&_dharma_event_mu);
}
el_val_t dharma_field(el_val_t event_type) {
const char* et = EL_CSTR(event_type);
if (!et) et = "";
/* Compute deadline: now + 30 seconds. */
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += 30;
DharmaEvent* found = NULL;
pthread_mutex_lock(&_dharma_event_mu);
while (1) {
/* Scan queue for matching type; pop and return first match. */
DharmaEvent* prev = NULL;
DharmaEvent* cur = _dharma_event_head;
while (cur) {
if (cur->event_type && strcmp(cur->event_type, et) == 0) {
if (prev) prev->next = cur->next;
else _dharma_event_head = cur->next;
if (_dharma_event_tail == cur) _dharma_event_tail = prev;
cur->next = NULL;
found = cur;
break;
}
prev = cur; cur = cur->next;
}
if (found) break;
int rc = pthread_cond_timedwait(&_dharma_event_cv, &_dharma_event_mu, &deadline);
if (rc == ETIMEDOUT) break;
}
pthread_mutex_unlock(&_dharma_event_mu);
if (!found) return el_map_new(0);
el_val_t m = el_map_new(0);
m = el_map_set(m, EL_STR(el_strdup("type")),
EL_STR(el_strdup(found->event_type ? found->event_type : "")));
m = el_map_set(m, EL_STR(el_strdup("payload")),
EL_STR(el_strdup(found->payload ? found->payload : "")));
m = el_map_set(m, EL_STR(el_strdup("source_cgi")),
EL_STR(el_strdup(found->source ? found->source : "")));
m = el_map_set(m, EL_STR(el_strdup("timestamp")), (el_val_t)found->timestamp);
free(found->event_type); free(found->payload); free(found->source); free(found);
return m;
}
/* Locate (or create) the local "dharma:self" node and the synthetic peer
* node "dharma:peer:<base_id>". Returns the index of the dharma-relation
* edge, or -1 if not found. If `create` is non-zero, ensure the nodes
* and edge exist (creating them as needed) and return the edge index. */
static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int create) {
if (!peer_base || !*peer_base) return -1;
EngramStore* g = engram_get();
const char* self_id = "dharma:self";
char peer_node[512];
snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", peer_base);
/* Look for the edge first. */
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
if (e->from_id && e->to_id &&
strcmp(e->from_id, self_id) == 0 &&
strcmp(e->to_id, peer_node) == 0 &&
e->relation && strcmp(e->relation, "dharma-relation") == 0) {
return i;
}
}
if (!create) return -1;
/* Ensure self node exists. We use a fixed id (not engram_new_id) so
* subsequent calls reuse the same one. */
if (!engram_find_node(self_id)) {
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = el_strdup(self_id);
n->content = el_strdup(_el_cgi_dharma_id ? _el_cgi_dharma_id : "(self)");
n->node_type = el_strdup("DharmaSelf");
n->label = el_strdup("dharma:self");
n->tier = el_strdup("Working");
n->tags = el_strdup("dharma");
n->metadata = el_strdup("{}");
n->salience = 1.0; n->importance = 1.0; n->confidence = 1.0;
int64_t now = engram_now_ms();
n->created_at = now; n->updated_at = now; n->last_activated = now;
n->layer_id = ENGRAM_LAYER_DEFAULT;
g->node_count++;
}
if (!engram_find_node(peer_node)) {
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = el_strdup(peer_node);
n->content = el_strdup(peer_base);
n->node_type = el_strdup("DharmaPeer");
n->label = el_strdup(peer_node);
n->tier = el_strdup("Working");
n->tags = el_strdup("dharma");
n->metadata = el_strdup("{}");
n->salience = 0.5; n->importance = 0.5; n->confidence = 1.0;
int64_t now = engram_now_ms();
n->created_at = now; n->updated_at = now; n->last_activated = now;
n->layer_id = ENGRAM_LAYER_DEFAULT;
g->node_count++;
}
/* Create the edge with weight 0.0 — caller will increment. */
engram_grow_edges();
EngramEdge* e = &g->edges[g->edge_count];
memset(e, 0, sizeof(*e));
e->id = engram_new_id();
e->from_id = el_strdup(self_id);
e->to_id = el_strdup(peer_node);
e->relation = el_strdup("dharma-relation");
e->metadata = el_strdup("{}");
e->weight = 0.0;
e->confidence = 1.0;
int64_t now = engram_now_ms();
e->created_at = now; e->updated_at = now;
e->layer_id = ENGRAM_LAYER_DEFAULT;
int64_t idx = g->edge_count;
g->edge_count++;
return idx;
}
void dharma_strengthen(el_val_t cgi_id, el_val_t weight) {
const char* id = EL_CSTR(cgi_id);
if (!id || !*id) return;
char* base = NULL; char* url = NULL;
dharma_parse_id(id, &base, &url);
free(url);
int64_t ei = dharma_find_or_create_relation_edge(base, 1);
free(base);
if (ei < 0) return;
EngramStore* g = engram_get();
double inc = engram_decode_score(weight);
if (!(inc >= 0.0)) inc = 0.0;
double w = g->edges[ei].weight + inc;
if (w < 0.0) w = 0.0;
if (w > 1.0) w = 1.0;
g->edges[ei].weight = w;
g->edges[ei].updated_at = engram_now_ms();
g->edges[ei].last_fired = g->edges[ei].updated_at;
}
el_val_t dharma_relationship(el_val_t cgi_id) {
const char* id = EL_CSTR(cgi_id);
if (!id || !*id) return el_from_float(0.0);
char* base = NULL; char* url = NULL;
dharma_parse_id(id, &base, &url);
free(url);
int64_t ei = dharma_find_or_create_relation_edge(base, 0);
free(base);
if (ei < 0) return el_from_float(0.0);
EngramStore* g = engram_get();
return el_from_float(g->edges[ei].weight);
}
el_val_t dharma_peers(void) {
/* Walk dharma-relation edges out of "dharma:self", weight > 0, sort desc. */
EngramStore* g = engram_get();
const char* self_id = "dharma:self";
typedef struct { char* peer_base; double weight; } PeerEntry;
PeerEntry* peers = malloc((size_t)(g->edge_count + 1) * sizeof(PeerEntry));
int64_t pcount = 0;
if (!peers) return el_list_empty();
for (int64_t i = 0; i < g->edge_count; i++) {
EngramEdge* e = &g->edges[i];
if (!e->from_id || !e->to_id) continue;
if (strcmp(e->from_id, self_id) != 0) continue;
if (!e->relation || strcmp(e->relation, "dharma-relation") != 0) continue;
if (e->weight <= 0.0) continue;
const char* prefix = "dharma:peer:";
size_t pl = strlen(prefix);
if (strncmp(e->to_id, prefix, pl) != 0) continue;
peers[pcount].peer_base = el_strdup(e->to_id + pl);
peers[pcount].weight = e->weight;
pcount++;
}
/* Sort desc by weight. */
for (int64_t i = 1; i < pcount; i++) {
PeerEntry key = peers[i];
int64_t j = i - 1;
while (j >= 0 && peers[j].weight < key.weight) {
peers[j + 1] = peers[j]; j--;
}
peers[j + 1] = key;
}
el_val_t out = el_list_empty();
for (int64_t i = 0; i < pcount; i++) {
out = el_list_append(out, EL_STR(peers[i].peer_base));
}
free(peers);
return out;
}
#endif /* HAVE_CURL — DHARMA network */
/* ── Batch 4: LLM (Anthropic API client) ─────────────────────────────────── */
/*
* All LLM builtins call https://api.anthropic.com/v1/messages with the API
* key from env ANTHROPIC_API_KEY. Default model is "claude-sonnet-4-5"
* when the supplied model is empty/null.
*
* `llm_call_agentic` runs a real multi-turn tool_use/tool_result loop.
* Tool handlers are registered with `llm_register_tool(name, fn_name)`,
* which dlsym()s the named symbol. Each tool handler has the C signature
* el_val_t handler(el_val_t input_json);
* and returns a JSON-string el_val_t result. Iteration is capped at 10.
*/
#ifdef HAVE_CURL
static const char* LLM_DEFAULT_MODEL = "claude-sonnet-4-5";
static const char* LLM_API_URL = "https://api.anthropic.com/v1/messages";
static const char* LLM_VERSION = "2023-06-01";
static const char* llm_resolve_model(const char* m) {
if (!m || !*m) return LLM_DEFAULT_MODEL;
return m;
}
/*
* ── Configurable LLM provider chain ──────────────────────────────────────────
*
* Providers are configured via indexed env vars. The runtime tries each in
* order (0, 1, 2, ...) and returns the first successful non-empty response.
*
* Per provider (N = 0, 1, 2, ...):
* NEURON_LLM_N_URL — endpoint URL (base URL; /v1/chat/completions appended
* if format is "openai" and not already in URL)
* NEURON_LLM_N_KEY — API key
* NEURON_LLM_N_FORMAT — "openai" (default) or "anthropic"
* NEURON_LLM_N_MODEL — model name override (optional)
*
* Example — Neuron inference primary, Anthropic fallback:
* NEURON_LLM_0_URL=https://soma.../v1/chat/completions
* NEURON_LLM_0_KEY=svc-key
* NEURON_LLM_0_FORMAT=openai
* NEURON_LLM_0_MODEL=neuron
* NEURON_LLM_1_URL=https://api.anthropic.com/v1/messages
* NEURON_LLM_1_KEY=sk-ant-...
* NEURON_LLM_1_FORMAT=anthropic
*
* If no NEURON_LLM_0_URL is set, falls back to legacy ANTHROPIC_API_KEY.
*/
#define LLM_MAX_PROVIDERS 16
/* forward declarations */
static el_val_t llm_extract_text(el_val_t resp_val);
static el_val_t llm_extract_text_openai(el_val_t resp_val);
static el_val_t llm_extract_text_openai(el_val_t resp_val) {
const char* resp = EL_CSTR(resp_val);
if (!resp || !*resp) return el_wrap_str(el_strdup(""));
if (resp[0] == '{' && strstr(resp, "\"error\"")) return el_wrap_str(el_strdup(""));
const char* choices = json_find_key(resp, "choices");
if (!choices || *choices != '[') return el_wrap_str(el_strdup(""));
choices++;
while (*choices == ' ' || *choices == '\t') choices++;
if (*choices != '{') return el_wrap_str(el_strdup(""));
const char* end = json_skip_value(choices);
size_t n = (size_t)(end - choices);
char* obj = malloc(n + 1); memcpy(obj, choices, n); obj[n] = '\0';
const char* msg = json_find_key(obj, "message");
if (!msg || *msg != '{') { free(obj); return el_wrap_str(el_strdup("")); }
const char* msg_end = json_skip_value(msg);
size_t mn = (size_t)(msg_end - msg);
char* msg_obj = malloc(mn + 1); memcpy(msg_obj, msg, mn); msg_obj[mn] = '\0';
const char* content = json_find_key(msg_obj, "content");
el_val_t result = el_wrap_str(el_strdup(""));
if (content && *content == '"') {
JsonParser jp = { .p = content, .end = content + strlen(content), .err = 0 };
char* text = jp_parse_string_raw(&jp);
if (!jp.err && text) result = el_wrap_str(text);
}
free(msg_obj); free(obj);
return result;
}
/* Send a request to one provider. Returns the raw response string.
* format: 0 = openai, 1 = anthropic */
static el_val_t llm_provider_request(const char* url, const char* key,
int format, const char* model,
const char* system_str,
const char* user_str) {
char* esc_sys = system_str && *system_str ? json_escape_alloc(system_str) : NULL;
char* esc_user = json_escape_alloc(user_str ? user_str : "");
JsonBuf b; jb_init(&b);
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
if (format == 0) { /* OpenAI */
char full_url[1024];
if (strstr(url, "/chat/completions") || strstr(url, "/messages")) {
snprintf(full_url, sizeof(full_url), "%s", url);
} else {
snprintf(full_url, sizeof(full_url), "%s/v1/chat/completions", url);
}
{ size_t n = strlen(key)+24; char* l=malloc(n); snprintf(l,n,"Authorization: Bearer %s",key); h=curl_slist_append(h,l); free(l); }
jb_putc(&b, '{');
jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : "neuron");
jb_puts(&b, ",\"max_tokens\":4096,\"messages\":[");
if (esc_sys && *esc_sys) { jb_puts(&b,"{\"role\":\"system\",\"content\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\"},"); }
jb_puts(&b, "{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
el_val_t resp = http_do("POST", full_url, b.buf, h);
curl_slist_free_all(h); free(b.buf);
if (esc_sys) free(esc_sys); free(esc_user);
return llm_extract_text_openai(resp);
} else { /* Anthropic */
{ size_t n = strlen(key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",key); h=curl_slist_append(h,l); free(l); }
{ size_t n = strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
jb_putc(&b, '{');
jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : LLM_DEFAULT_MODEL);
jb_puts(&b, ",\"max_tokens\":4096");
if (esc_sys && *esc_sys) { jb_puts(&b,",\"system\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\""); }
jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
el_val_t resp = http_do("POST", url, b.buf, h);
curl_slist_free_all(h); free(b.buf);
if (esc_sys) free(esc_sys); free(esc_user);
return llm_extract_text(resp);
}
}
static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
char url_key[64], key_key[64], fmt_key[64], model_key[64];
for (int i = 0; i < LLM_MAX_PROVIDERS; i++) {
snprintf(url_key, sizeof(url_key), "NEURON_LLM_%d_URL", i);
snprintf(key_key, sizeof(key_key), "NEURON_LLM_%d_KEY", i);
snprintf(fmt_key, sizeof(fmt_key), "NEURON_LLM_%d_FORMAT", i);
snprintf(model_key, sizeof(model_key), "NEURON_LLM_%d_MODEL", i);
const char* url = getenv(url_key);
const char* key = getenv(key_key);
if (!url || !*url || !key || !*key) break; /* end of chain */
const char* fmt_s = getenv(fmt_key);
int fmt = (fmt_s && strcmp(fmt_s, "anthropic") == 0) ? 1 : 0;
const char* model = getenv(model_key);
fprintf(stderr, "[llm] trying provider %d (%s)\n", i, url);
el_val_t result = llm_provider_request(url, key, fmt, model, system_str, user_str);
const char* t = EL_CSTR(result);
if (t && *t && t[0] != '{') return result; /* success */
fprintf(stderr, "[llm] provider %d failed or empty, trying next\n", i);
}
/* Legacy fallback: ANTHROPIC_API_KEY */
const char* api_key = getenv("ANTHROPIC_API_KEY");
if (!api_key || !*api_key) return http_error_json("no LLM providers configured");
fprintf(stderr, "[llm] using legacy ANTHROPIC_API_KEY fallback\n");
return llm_provider_request(LLM_API_URL, api_key, 1, NULL, system_str, user_str);
}
/* Legacy llm_request — kept for backward compat with agentic loop internals */
static el_val_t llm_request(const char* json_body) {
const char* api_key = getenv("ANTHROPIC_API_KEY");
if (!api_key || !*api_key) return http_error_json("ANTHROPIC_API_KEY not set");
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
{ size_t n=strlen(api_key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",api_key); h=curl_slist_append(h,l); free(l); }
{ size_t n=strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
el_val_t resp = http_do("POST", LLM_API_URL, json_body, h);
curl_slist_free_all(h);
return resp;
}
/* Extract concatenated assistant text from an Anthropic /v1/messages
* response. The response shape is:
* {"content":[{"type":"text","text":"..."}, ...], ...}
* If parsing fails, returns the raw response so the caller can inspect.
*/
static el_val_t llm_extract_text(el_val_t resp_val) {
const char* resp = EL_CSTR(resp_val);
if (!resp || !*resp) return el_wrap_str(el_strdup(""));
/* If error JSON, propagate as-is. */
if (resp[0] == '{' && strstr(resp, "\"error\"")) {
return el_wrap_str(el_strdup(resp));
}
/* Find "content":[ ... ] */
const char* p = json_find_key(resp, "content");
if (!p) return el_wrap_str(el_strdup(resp));
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') return el_wrap_str(el_strdup(resp));
p++;
JsonBuf out; jb_init(&out);
while (*p && *p != ']') {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p != '{') break;
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
char* obj = malloc(n + 1);
memcpy(obj, p, n); obj[n] = '\0';
const char* type_p = json_find_key(obj, "type");
if (type_p && *type_p == '"') {
JsonParser jp = { .p = type_p, .end = type_p + strlen(type_p), .err = 0 };
char* type_s = jp_parse_string_raw(&jp);
if (!jp.err && type_s && strcmp(type_s, "text") == 0) {
const char* tp = json_find_key(obj, "text");
if (tp && *tp == '"') {
JsonParser jp2 = { .p = tp, .end = tp + strlen(tp), .err = 0 };
char* text_s = jp_parse_string_raw(&jp2);
if (!jp2.err && text_s) jb_puts(&out, text_s);
free(text_s);
}
}
free(type_s);
}
free(obj);
p = end;
}
return el_wrap_str(out.buf);
}
el_val_t llm_call(el_val_t model, el_val_t prompt) {
const char* u = EL_CSTR(prompt); if (!u) u = "";
return llm_chain_call(NULL, u);
}
el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) {
const char* s = EL_CSTR(system_prompt); if (!s) s = "";
const char* u = EL_CSTR(user_prompt); if (!u) u = "";
return llm_chain_call(s, u);
}
/* ── Tool registry for llm_call_agentic ─────────────────────────────────── */
typedef el_val_t (*llm_tool_fn)(el_val_t input);
typedef struct LlmToolEntry {
char* name;
llm_tool_fn fn;
} LlmToolEntry;
static LlmToolEntry _llm_tools[64];
static size_t _llm_tool_count = 0;
static pthread_mutex_t _llm_tool_mu = PTHREAD_MUTEX_INITIALIZER;
static llm_tool_fn llm_tool_lookup(const char* name) {
if (!name) return NULL;
llm_tool_fn fn = NULL;
pthread_mutex_lock(&_llm_tool_mu);
for (size_t i = 0; i < _llm_tool_count; i++) {
if (strcmp(_llm_tools[i].name, name) == 0) { fn = _llm_tools[i].fn; break; }
}
pthread_mutex_unlock(&_llm_tool_mu);
return fn;
}
void llm_register_tool(el_val_t name, el_val_t handler_fn_name) {
const char* nm = EL_CSTR(name);
const char* sym = EL_CSTR(handler_fn_name);
if (!nm || !*nm || !sym || !*sym) return;
void* p = dlsym(RTLD_DEFAULT, sym);
if (!p) {
fprintf(stderr, "[llm_register_tool] symbol not found: %s\n", sym);
return;
}
pthread_mutex_lock(&_llm_tool_mu);
/* Replace existing entry by name. */
for (size_t i = 0; i < _llm_tool_count; i++) {
if (strcmp(_llm_tools[i].name, nm) == 0) {
_llm_tools[i].fn = (llm_tool_fn)p;
pthread_mutex_unlock(&_llm_tool_mu);
return;
}
}
if (_llm_tool_count < sizeof(_llm_tools) / sizeof(_llm_tools[0])) {
_llm_tools[_llm_tool_count].name = el_strdup(nm);
_llm_tools[_llm_tool_count].fn = (llm_tool_fn)p;
_llm_tool_count++;
}
pthread_mutex_unlock(&_llm_tool_mu);
}
/* Serialize the El `tools` list into the JSON `tools:[...]` field expected
* by the Anthropic API. Each tool is an ElMap with name/description/
* input_schema. input_schema is treated as either a JSON-object string
* (passed through verbatim) or a missing field (substitute {}). */
static void llm_emit_tools_json(JsonBuf* b, el_val_t tools_list) {
jb_putc(b, '[');
ElList* lst = (ElList*)(uintptr_t)tools_list;
int64_t n = lst ? lst->length : 0;
for (int64_t i = 0; i < n; i++) {
if (i > 0) jb_putc(b, ',');
ElMap* tm = as_map(lst->elems[i]);
const char* name = "";
const char* desc = "";
const char* schema = "{}";
if (tm) {
for (int64_t k = 0; k < tm->count; k++) {
const char* key = EL_CSTR(tm->keys[k]);
const char* val = EL_CSTR(tm->values[k]);
if (!key || !val) continue;
if (strcmp(key, "name") == 0) name = val;
else if (strcmp(key, "description") == 0) desc = val;
else if (strcmp(key, "input_schema") == 0) schema = val;
}
}
char* esc_name = json_escape_alloc(name);
char* esc_desc = json_escape_alloc(desc);
jb_puts(b, "{\"name\":\""); jb_puts(b, esc_name);
jb_puts(b, "\",\"description\":\""); jb_puts(b, esc_desc);
jb_puts(b, "\",\"input_schema\":"); jb_puts(b, schema && *schema ? schema : "{}");
jb_putc(b, '}');
free(esc_name); free(esc_desc);
}
jb_putc(b, ']');
}
/* Walk the assistant `content` array and emit each block back into b,
* preserving the verbatim JSON of every block — used to re-include the
* assistant turn in the next request. */
static void llm_emit_content_blocks(JsonBuf* b, const char* resp) {
const char* p = json_find_key(resp, "content");
jb_putc(b, '[');
if (!p) { jb_putc(b, ']'); return; }
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') { jb_putc(b, ']'); return; }
p++;
int first = 1;
while (*p && *p != ']') {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p != '{') break;
const char* end = json_skip_value(p);
if (!first) jb_putc(b, ',');
first = 0;
size_t n = (size_t)(end - p);
jb_reserve(b, n);
memcpy(b->buf + b->len, p, n);
b->len += n;
b->buf[b->len] = '\0';
p = end;
}
jb_putc(b, ']');
}
/* Concatenate all "text" blocks from a response. Returns owned string. */
static char* llm_concat_text_blocks(const char* resp) {
JsonBuf out; jb_init(&out);
if (!resp) return out.buf;
const char* p = json_find_key(resp, "content");
if (!p) return out.buf;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') return out.buf;
p++;
while (*p && *p != ']') {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p != '{') break;
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
char* obj = malloc(n + 1);
memcpy(obj, p, n); obj[n] = '\0';
const char* tp = json_find_key(obj, "type");
if (tp && *tp == '"') {
JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
char* tname = jp_parse_string_raw(&jp);
if (!jp.err && tname && strcmp(tname, "text") == 0) {
const char* xp = json_find_key(obj, "text");
if (xp && *xp == '"') {
JsonParser jp2 = { .p = xp, .end = xp + strlen(xp), .err = 0 };
char* txt = jp_parse_string_raw(&jp2);
if (!jp2.err && txt) jb_puts(&out, txt);
free(txt);
}
}
free(tname);
}
free(obj);
p = end;
}
return out.buf;
}
/* Build tool_result message blocks for every tool_use in a response.
* Appends to `b` an array element for each tool_use; caller wraps. */
static int llm_build_tool_results(JsonBuf* b, const char* resp) {
int any = 0;
const char* p = json_find_key(resp, "content");
if (!p) return 0;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') return 0;
p++;
while (*p && *p != ']') {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p != '{') break;
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
char* obj = malloc(n + 1);
memcpy(obj, p, n); obj[n] = '\0';
const char* tp = json_find_key(obj, "type");
char* type_s = NULL;
if (tp && *tp == '"') {
JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
type_s = jp_parse_string_raw(&jp);
}
if (type_s && strcmp(type_s, "tool_use") == 0) {
/* Extract id, name, input. */
char* id_s = NULL; char* name_s = NULL;
const char* idp = json_find_key(obj, "id");
if (idp && *idp == '"') {
JsonParser jp = { .p = idp, .end = idp + strlen(idp), .err = 0 };
id_s = jp_parse_string_raw(&jp);
}
const char* np = json_find_key(obj, "name");
if (np && *np == '"') {
JsonParser jp = { .p = np, .end = np + strlen(np), .err = 0 };
name_s = jp_parse_string_raw(&jp);
}
el_val_t input_raw = json_get_raw(EL_STR(obj), EL_STR("input"));
const char* input_s = EL_CSTR(input_raw);
if (!input_s || !*input_s) input_s = "{}";
llm_tool_fn fn = llm_tool_lookup(name_s ? name_s : "");
char* result = NULL;
int is_error = 0;
if (!fn) {
size_t en = strlen(name_s ? name_s : "(null)") + 64;
result = malloc(en);
snprintf(result, en, "{\"error\":\"tool not registered: %s\"}",
name_s ? name_s : "(null)");
is_error = 1;
} else {
el_val_t out = fn(EL_STR(input_s));
const char* os = EL_CSTR(out);
result = el_strdup(os ? os : "");
}
if (any) jb_putc(b, ',');
char* esc_id = json_escape_alloc(id_s ? id_s : "");
char* esc_res = json_escape_alloc(result ? result : "");
jb_puts(b, "{\"type\":\"tool_result\",\"tool_use_id\":\"");
jb_puts(b, esc_id);
jb_puts(b, "\",\"content\":\"");
jb_puts(b, esc_res);
jb_puts(b, "\"");
if (is_error) jb_puts(b, ",\"is_error\":true");
jb_putc(b, '}');
free(esc_id); free(esc_res); free(result);
free(id_s); free(name_s);
any = 1;
}
free(type_s);
free(obj);
p = end;
}
return any;
}
el_val_t llm_call_agentic(el_val_t model, el_val_t system, el_val_t user, el_val_t tools) {
/* Empty tools list → degrade to plain system call. */
ElList* tl = (ElList*)(uintptr_t)tools;
if (!tl || tl->length == 0) {
return llm_call_system(model, system, user);
}
const char* m = llm_resolve_model(EL_CSTR(model));
const char* sys_p = EL_CSTR(system); if (!sys_p) sys_p = "";
const char* usr_p = EL_CSTR(user); if (!usr_p) usr_p = "";
/* Build the static parts: tools JSON and system prompt — these don't
* change across iterations. */
JsonBuf tools_buf; jb_init(&tools_buf);
llm_emit_tools_json(&tools_buf, tools);
char* esc_sys = json_escape_alloc(sys_p);
/* messages array, accumulated as a mutable JSON fragment (no surrounding
* brackets — emitted at request time). */
JsonBuf msgs; jb_init(&msgs);
/* First user message. */
char* esc_user = json_escape_alloc(usr_p);
jb_puts(&msgs, "{\"role\":\"user\",\"content\":\"");
jb_puts(&msgs, esc_user);
jb_puts(&msgs, "\"}");
free(esc_user);
char* last_text = el_strdup("");
el_val_t final_out = 0;
int reached_cap = 1;
for (int iter = 0; iter < 10; iter++) {
/* Build request body. */
JsonBuf body; jb_init(&body);
jb_putc(&body, '{');
jb_puts(&body, "\"model\":"); jb_emit_escaped(&body, m);
jb_puts(&body, ",\"max_tokens\":4096");
if (*sys_p) {
jb_puts(&body, ",\"system\":\"");
jb_puts(&body, esc_sys);
jb_puts(&body, "\"");
}
jb_puts(&body, ",\"tools\":");
jb_puts(&body, tools_buf.buf);
jb_puts(&body, ",\"messages\":[");
jb_puts(&body, msgs.buf);
jb_puts(&body, "]}");
el_val_t resp_v = llm_request(body.buf);
free(body.buf);
const char* resp = EL_CSTR(resp_v);
if (!resp || !*resp) {
final_out = http_error_json("empty response");
reached_cap = 0;
break;
}
if (resp[0] == '{' && strstr(resp, "\"error\"") &&
!json_find_key(resp, "content")) {
final_out = el_wrap_str(el_strdup(resp));
reached_cap = 0;
break;
}
/* Update last_text from this response. */
free(last_text);
last_text = llm_concat_text_blocks(resp);
/* Inspect stop_reason. */
el_val_t sr_v = json_get_string(EL_STR(resp), EL_STR("stop_reason"));
const char* sr = EL_CSTR(sr_v); if (!sr) sr = "";
if (strcmp(sr, "end_turn") == 0) {
final_out = el_wrap_str(el_strdup(last_text));
reached_cap = 0;
break;
}
if (strcmp(sr, "max_tokens") == 0) {
size_t ln = strlen(last_text) + 16;
char* out = malloc(ln);
snprintf(out, ln, "%s\n[truncated]", last_text);
final_out = el_wrap_str(out);
reached_cap = 0;
break;
}
if (strcmp(sr, "tool_use") != 0) {
/* Unexpected stop reason; return the text we have. */
final_out = el_wrap_str(el_strdup(last_text));
reached_cap = 0;
break;
}
/* Append the assistant turn (raw content blocks) to messages. */
JsonBuf ab; jb_init(&ab);
jb_puts(&ab, ",{\"role\":\"assistant\",\"content\":");
llm_emit_content_blocks(&ab, resp);
jb_putc(&ab, '}');
jb_puts(&msgs, ab.buf);
free(ab.buf);
/* Build tool_result message. */
JsonBuf tr; jb_init(&tr);
jb_puts(&tr, ",{\"role\":\"user\",\"content\":[");
int any = llm_build_tool_results(&tr, resp);
jb_puts(&tr, "]}");
if (any) {
jb_puts(&msgs, tr.buf);
}
free(tr.buf);
}
if (reached_cap) {
size_t ln = strlen(last_text) + 32;
char* out = malloc(ln);
snprintf(out, ln, "[loop_cap_reached]\n%s", last_text);
final_out = el_wrap_str(out);
}
free(last_text);
free(esc_sys);
free(tools_buf.buf);
free(msgs.buf);
return final_out;
}
/* base64-encode arbitrary bytes (returns owned C string).
* Internal helper for llm_vision; the public crypto entry point that El
* programs call is `base64_encode(el_val_t)` defined in the crypto block
* at the end of this file. */
static char* el_b64_encode_internal(const unsigned char* src, size_t n) {
static const char tbl[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t out_len = 4 * ((n + 2) / 3);
char* out = malloc(out_len + 1);
if (!out) return NULL;
size_t o = 0;
for (size_t i = 0; i < n;) {
uint32_t v = 0; int got = 0;
v |= (uint32_t)src[i++] << 16; got++;
if (i < n) { v |= (uint32_t)src[i++] << 8; got++; }
if (i < n) { v |= (uint32_t)src[i++]; got++; }
out[o++] = tbl[(v >> 18) & 0x3f];
out[o++] = tbl[(v >> 12) & 0x3f];
out[o++] = (got > 1) ? tbl[(v >> 6) & 0x3f] : '=';
out[o++] = (got > 2) ? tbl[v & 0x3f] : '=';
}
out[o] = '\0';
return out;
}
el_val_t llm_vision(el_val_t model, el_val_t system, el_val_t prompt, el_val_t image_url_or_b64) {
const char* m = llm_resolve_model(EL_CSTR(model));
const char* s = EL_CSTR(system); if (!s) s = "";
const char* u = EL_CSTR(prompt); if (!u) u = "";
const char* img = EL_CSTR(image_url_or_b64); if (!img) img = "";
/* Choose source mode */
char* image_block = NULL;
if (strncasecmp(img, "http://", 7) == 0 || strncasecmp(img, "https://", 8) == 0) {
char* esc_url = json_escape_alloc(img);
size_t n = strlen(esc_url) + 128;
image_block = malloc(n);
snprintf(image_block, n,
"{\"type\":\"image\",\"source\":{\"type\":\"url\",\"url\":\"%s\"}}",
esc_url);
free(esc_url);
} else if (strncmp(img, "data:", 5) == 0) {
/* Inline data URL: split media-type and base64 */
const char* semi = strchr(img + 5, ';');
const char* comma = strchr(img + 5, ',');
char media[64] = "image/png";
if (semi && comma && semi < comma) {
size_t ml = (size_t)(semi - (img + 5));
if (ml >= sizeof(media)) ml = sizeof(media) - 1;
memcpy(media, img + 5, ml); media[ml] = '\0';
}
const char* b64 = comma ? comma + 1 : "";
char* esc_media = json_escape_alloc(media);
char* esc_b64 = json_escape_alloc(b64);
size_t n = strlen(esc_media) + strlen(esc_b64) + 192;
image_block = malloc(n);
snprintf(image_block, n,
"{\"type\":\"image\",\"source\":{\"type\":\"base64\","
"\"media_type\":\"%s\",\"data\":\"%s\"}}",
esc_media, esc_b64);
free(esc_media); free(esc_b64);
} else if (*img) {
/* Treat as file path: read, base64-encode, attach. */
FILE* f = fopen(img, "rb");
if (!f) {
char err[256]; snprintf(err, sizeof(err), "cannot open image: %s", img);
return http_error_json(err);
}
fseek(f, 0, SEEK_END);
long sz = ftell(f);
rewind(f);
if (sz <= 0) { fclose(f); return http_error_json("empty image file"); }
unsigned char* buf = malloc((size_t)sz);
if (!buf) { fclose(f); return http_error_json("oom"); }
size_t got = fread(buf, 1, (size_t)sz, f);
fclose(f);
char* b64 = el_b64_encode_internal(buf, got);
free(buf);
if (!b64) return http_error_json("base64 encode failed");
const char* media = "image/png";
size_t ilen = strlen(img);
if (ilen >= 4) {
if (strcasecmp(img + ilen - 4, ".jpg") == 0 ||
(ilen >= 5 && strcasecmp(img + ilen - 5, ".jpeg") == 0)) media = "image/jpeg";
else if (strcasecmp(img + ilen - 4, ".gif") == 0) media = "image/gif";
else if (strcasecmp(img + ilen - 4, ".webp") == 0) media = "image/webp";
}
char* esc_b64 = json_escape_alloc(b64); free(b64);
size_t n = strlen(esc_b64) + 192;
image_block = malloc(n);
snprintf(image_block, n,
"{\"type\":\"image\",\"source\":{\"type\":\"base64\","
"\"media_type\":\"%s\",\"data\":\"%s\"}}",
media, esc_b64);
free(esc_b64);
}
char* esc_sys = json_escape_alloc(s);
char* esc_user = json_escape_alloc(u);
JsonBuf b; jb_init(&b);
jb_putc(&b, '{');
jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, m);
jb_puts(&b, ",\"max_tokens\":4096");
if (*s) {
jb_puts(&b, ",\"system\":\"");
jb_puts(&b, esc_sys);
jb_puts(&b, "\"");
}
jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":[");
if (image_block) {
jb_puts(&b, image_block);
jb_putc(&b, ',');
}
jb_puts(&b, "{\"type\":\"text\",\"text\":\"");
jb_puts(&b, esc_user);
jb_puts(&b, "\"}]}]}");
free(esc_sys); free(esc_user); free(image_block);
el_val_t resp = llm_request(b.buf);
free(b.buf);
return llm_extract_text(resp);
}
el_val_t llm_models(void) {
el_val_t lst = el_list_empty();
lst = el_list_append(lst, el_wrap_str(el_strdup("claude-sonnet-4-5")));
lst = el_list_append(lst, el_wrap_str(el_strdup("claude-opus-4-7")));
lst = el_list_append(lst, el_wrap_str(el_strdup("claude-haiku-4-5")));
return lst;
}
#endif /* HAVE_CURL */
/* ── Native VM builtin aliases ──────────────────────────────────────────────
* El source files use native_* names (El VM builtins).
* When compiled to C, these map directly to el_* runtime functions. */
el_val_t native_list_get(el_val_t list, el_val_t index) {
return el_list_get(list, index);
}
el_val_t native_list_len(el_val_t list) {
return el_list_len(list);
}
el_val_t native_list_append(el_val_t list, el_val_t elem) {
return el_list_append(list, elem);
}
el_val_t native_list_empty(void) {
return el_list_empty();
}
el_val_t native_list_clone(el_val_t list) {
return el_list_clone(list);
}
el_val_t native_string_chars(el_val_t sv) {
const char* s = EL_CSTR(sv);
el_val_t result = el_list_empty();
if (!s) return result;
while (*s) {
char buf[2];
buf[0] = *s;
buf[1] = '\0';
result = el_list_append(result, EL_STR(strdup(buf)));
s++;
}
return result;
}
el_val_t native_int_to_str(el_val_t n) {
return int_to_str(n);
}
/* ── Method-call shorthand aliases ──────────────────────────────────────────
* Short names that result from the method-call convention:
* myList.append(x) → append(myList, x)
* myList.len() → len(myList)
* myList.get(i) → get(myList, i)
* myMap.map_get(k) → map_get(myMap, k)
* myMap.map_set(k,v) → map_set(myMap, k, v) */
el_val_t append(el_val_t list, el_val_t elem) { return el_list_append(list, elem); }
el_val_t len(el_val_t list) { return el_list_len(list); }
el_val_t get(el_val_t list, el_val_t index) { return el_list_get(list, index); }
el_val_t map_get(el_val_t map, el_val_t key) { return el_map_get(map, key); }
el_val_t map_set(el_val_t map, el_val_t key, el_val_t value) { return el_map_set(map, key, value); }
/* ── Crypto primitives ──────────────────────────────────────────────────────
*
* SHA-256 implementation adapted from Brad Conte's public-domain reference
* (https://github.com/B-Con/crypto-algorithms/blob/master/sha256.c, public
* domain per the project's LICENSE). HMAC follows RFC 2104. Base64 encoding
* follows RFC 4648; the URL-safe variant uses the alphabet from §5 of the
* RFC and omits padding (per JWT/JWS convention).
*
* Self-contained: no OpenSSL/libcrypto dependency. The runtime keeps its
* existing `-lcurl -lpthread -ldl -lm` link line.
*
* Binary outputs (sha256_bytes, hmac_sha256_bytes) tag their buffer with a
* magic header so base64_encode/base64url_encode can recover the exact byte
* length even when the payload contains embedded NULs. Plain C strings
* (without the header) fall back to strlen(), preserving the existing API
* shape for normal text inputs. */
/* Magic-header for length-tagged binary buffers. Layout:
* [ uint32_t magic = EL_MAGIC_BIN ][ uint32_t length ][ data... ][ \0 ]
* The returned el_val_t points at `data`, so consumers that strlen() it still
* get a sensible (though possibly truncated) view. el_bin_len() recovers the
* true length by sniffing the 8 bytes preceding the pointer.
*
* Magic value chosen with high MSB so it cannot collide with printable ASCII
* (the same discriminator pattern used by EL_MAGIC_LIST / EL_MAGIC_MAP). */
#define EL_MAGIC_BIN 0xE1B17EAFu
typedef struct {
uint32_t magic;
uint32_t length;
} el_bin_hdr_t;
/* Allocate a length-tagged binary buffer; returns pointer to the data area. */
static unsigned char* el_bin_alloc(size_t len) {
el_bin_hdr_t* hdr = (el_bin_hdr_t*)malloc(sizeof(el_bin_hdr_t) + len + 1);
if (!hdr) { fputs("el_runtime: out of memory (bin)\n", stderr); exit(1); }
hdr->magic = EL_MAGIC_BIN;
hdr->length = (uint32_t)len;
unsigned char* data = (unsigned char*)(hdr + 1);
data[len] = '\0'; /* keep NUL-terminated for accidental strlen calls */
return data;
}
/* Recover length from a possibly-tagged buffer. Returns 1 if tagged. */
static int el_bin_lookup(const void* p, size_t* out_len) {
if (!p) { *out_len = 0; return 0; }
/* Avoid reading off the front of a page on tiny pointers (e.g. NULs
* passed in as int-cast values). 4096 is a safe lower bound on any
* platform we target. */
if ((uintptr_t)p < 4096) return 0;
const el_bin_hdr_t* hdr = (const el_bin_hdr_t*)((const char*)p - sizeof(el_bin_hdr_t));
if (hdr->magic != EL_MAGIC_BIN) return 0;
*out_len = hdr->length;
return 1;
}
/* Effective input length: tagged length if present, else strlen. */
static size_t el_input_len(const char* s) {
size_t n;
if (el_bin_lookup(s, &n)) return n;
return s ? strlen(s) : 0;
}
/* ─── SHA-256 (Brad Conte / public domain) ──────────────────────────────── */
typedef struct {
unsigned char data[64];
uint32_t datalen;
uint64_t bitlen;
uint32_t state[8];
} el_sha256_ctx_t;
static const uint32_t el_sha256_k[64] = {
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
};
#define EL_ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define EL_CH(x,y,z) (((x) & (y)) ^ (~(x) & (z)))
#define EL_MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EL_EP0(x) (EL_ROTR(x,2) ^ EL_ROTR(x,13) ^ EL_ROTR(x,22))
#define EL_EP1(x) (EL_ROTR(x,6) ^ EL_ROTR(x,11) ^ EL_ROTR(x,25))
#define EL_SIG0(x) (EL_ROTR(x,7) ^ EL_ROTR(x,18) ^ ((x) >> 3))
#define EL_SIG1(x) (EL_ROTR(x,17) ^ EL_ROTR(x,19) ^ ((x) >> 10))
static void el_sha256_transform(el_sha256_ctx_t* ctx, const unsigned char* data) {
uint32_t a, b, c, d, e, f, g, h, t1, t2, m[64];
int i, j;
for (i = 0, j = 0; i < 16; ++i, j += 4) {
m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16)
| ((uint32_t)data[j + 2] << 8) | (uint32_t)data[j + 3];
}
for (; i < 64; ++i) {
m[i] = EL_SIG1(m[i-2]) + m[i-7] + EL_SIG0(m[i-15]) + m[i-16];
}
a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
for (i = 0; i < 64; ++i) {
t1 = h + EL_EP1(e) + EL_CH(e,f,g) + el_sha256_k[i] + m[i];
t2 = EL_EP0(a) + EL_MAJ(a,b,c);
h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
}
ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
}
static void el_sha256_init(el_sha256_ctx_t* ctx) {
ctx->datalen = 0;
ctx->bitlen = 0;
ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85;
ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a;
ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c;
ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19;
}
static void el_sha256_update(el_sha256_ctx_t* ctx, const unsigned char* data, size_t len) {
for (size_t i = 0; i < len; ++i) {
ctx->data[ctx->datalen++] = data[i];
if (ctx->datalen == 64) {
el_sha256_transform(ctx, ctx->data);
ctx->bitlen += 512;
ctx->datalen = 0;
}
}
}
static void el_sha256_final(el_sha256_ctx_t* ctx, unsigned char hash[32]) {
uint32_t i = ctx->datalen;
if (ctx->datalen < 56) {
ctx->data[i++] = 0x80;
while (i < 56) ctx->data[i++] = 0x00;
} else {
ctx->data[i++] = 0x80;
while (i < 64) ctx->data[i++] = 0x00;
el_sha256_transform(ctx, ctx->data);
memset(ctx->data, 0, 56);
}
ctx->bitlen += (uint64_t)ctx->datalen * 8;
ctx->data[63] = (unsigned char)( ctx->bitlen & 0xff);
ctx->data[62] = (unsigned char)((ctx->bitlen >> 8) & 0xff);
ctx->data[61] = (unsigned char)((ctx->bitlen >> 16) & 0xff);
ctx->data[60] = (unsigned char)((ctx->bitlen >> 24) & 0xff);
ctx->data[59] = (unsigned char)((ctx->bitlen >> 32) & 0xff);
ctx->data[58] = (unsigned char)((ctx->bitlen >> 40) & 0xff);
ctx->data[57] = (unsigned char)((ctx->bitlen >> 48) & 0xff);
ctx->data[56] = (unsigned char)((ctx->bitlen >> 56) & 0xff);
el_sha256_transform(ctx, ctx->data);
for (i = 0; i < 4; ++i) {
hash[i] = (ctx->state[0] >> (24 - i * 8)) & 0xff;
hash[i + 4] = (ctx->state[1] >> (24 - i * 8)) & 0xff;
hash[i + 8] = (ctx->state[2] >> (24 - i * 8)) & 0xff;
hash[i + 12] = (ctx->state[3] >> (24 - i * 8)) & 0xff;
hash[i + 16] = (ctx->state[4] >> (24 - i * 8)) & 0xff;
hash[i + 20] = (ctx->state[5] >> (24 - i * 8)) & 0xff;
hash[i + 24] = (ctx->state[6] >> (24 - i * 8)) & 0xff;
hash[i + 28] = (ctx->state[7] >> (24 - i * 8)) & 0xff;
}
}
static void el_sha256_oneshot(const unsigned char* data, size_t len, unsigned char out[32]) {
el_sha256_ctx_t c;
el_sha256_init(&c);
el_sha256_update(&c, data, len);
el_sha256_final(&c, out);
}
/* ─── HMAC-SHA-256 (RFC 2104) ───────────────────────────────────────────── */
static void el_hmac_sha256(const unsigned char* key, size_t key_len,
const unsigned char* msg, size_t msg_len,
unsigned char out[32]) {
unsigned char k[64];
unsigned char k_ipad[64];
unsigned char k_opad[64];
unsigned char inner[32];
if (key_len > 64) {
el_sha256_oneshot(key, key_len, k);
memset(k + 32, 0, 32);
} else {
memcpy(k, key, key_len);
memset(k + key_len, 0, 64 - key_len);
}
for (int i = 0; i < 64; ++i) {
k_ipad[i] = k[i] ^ 0x36;
k_opad[i] = k[i] ^ 0x5c;
}
{
el_sha256_ctx_t c;
el_sha256_init(&c);
el_sha256_update(&c, k_ipad, 64);
el_sha256_update(&c, msg, msg_len);
el_sha256_final(&c, inner);
}
{
el_sha256_ctx_t c;
el_sha256_init(&c);
el_sha256_update(&c, k_opad, 64);
el_sha256_update(&c, inner, 32);
el_sha256_final(&c, out);
}
}
/* ─── Hex helper ────────────────────────────────────────────────────────── */
static el_val_t el_hex_encode(const unsigned char* data, size_t len) {
static const char digits[] = "0123456789abcdef";
char* out = el_strbuf(len * 2);
for (size_t i = 0; i < len; ++i) {
out[i * 2] = digits[(data[i] >> 4) & 0xf];
out[i * 2 + 1] = digits[ data[i] & 0xf];
}
out[len * 2] = '\0';
return el_wrap_str(out);
}
/* ─── Base64 (RFC 4648) ─────────────────────────────────────────────────── */
static const char el_b64_std_alphabet[64] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static const char el_b64_url_alphabet[64] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
el_val_t el_base64_encode_n(const unsigned char* data, size_t len, int url_safe) {
const char* alphabet = url_safe ? el_b64_url_alphabet : el_b64_std_alphabet;
/* Standard form is padded to multiple of 4; URL-safe omits padding. */
size_t out_cap = ((len + 2) / 3) * 4 + 1;
char* out = el_strbuf(out_cap);
size_t i = 0, j = 0;
while (i + 3 <= len) {
uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8) | (uint32_t)data[i+2];
out[j++] = alphabet[(v >> 18) & 0x3f];
out[j++] = alphabet[(v >> 12) & 0x3f];
out[j++] = alphabet[(v >> 6) & 0x3f];
out[j++] = alphabet[ v & 0x3f];
i += 3;
}
size_t rem = len - i;
if (rem == 1) {
uint32_t v = (uint32_t)data[i] << 16;
out[j++] = alphabet[(v >> 18) & 0x3f];
out[j++] = alphabet[(v >> 12) & 0x3f];
if (!url_safe) { out[j++] = '='; out[j++] = '='; }
} else if (rem == 2) {
uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8);
out[j++] = alphabet[(v >> 18) & 0x3f];
out[j++] = alphabet[(v >> 12) & 0x3f];
out[j++] = alphabet[(v >> 6) & 0x3f];
if (!url_safe) { out[j++] = '='; }
}
out[j] = '\0';
return el_wrap_str(out);
}
/* Decode either alphabet — accepts both '+/' and '-_' transparently, and
* tolerates missing padding (which JWTs typically omit). Whitespace is
* skipped for robustness. Invalid characters cause the decode to stop and
* the partial result so far is returned. */
static el_val_t el_base64_decode_any(const char* in) {
if (!in) {
unsigned char* empty = el_bin_alloc(0);
return EL_STR((char*)empty);
}
size_t in_len = strlen(in);
/* Worst case: 3 output bytes per 4 input chars, +1 NUL slack. */
unsigned char* out = el_bin_alloc(((in_len + 3) / 4) * 3 + 1);
int8_t lut[256];
for (int i = 0; i < 256; ++i) lut[i] = -1;
for (int i = 0; i < 64; ++i) lut[(unsigned char)el_b64_std_alphabet[i]] = (int8_t)i;
/* Allow URL-safe characters too (so one decoder handles both forms). */
lut[(unsigned char)'-'] = 62;
lut[(unsigned char)'_'] = 63;
uint32_t buf = 0;
int bits = 0;
size_t o = 0;
for (size_t i = 0; i < in_len; ++i) {
unsigned char c = (unsigned char)in[i];
if (c == '=' || c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
int8_t v = lut[c];
if (v < 0) break; /* invalid char — stop */
buf = (buf << 6) | (uint32_t)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (unsigned char)((buf >> bits) & 0xff);
}
}
/* Patch the length header to the actual decoded length. */
el_bin_hdr_t* hdr = (el_bin_hdr_t*)((char*)out - sizeof(el_bin_hdr_t));
hdr->length = (uint32_t)o;
out[o] = '\0';
return EL_STR((char*)out);
}
/* ─── Public crypto entry points ────────────────────────────────────────── */
el_val_t el_sha256_bytes_n(const unsigned char* data, size_t len) {
unsigned char* out = el_bin_alloc(32);
el_sha256_oneshot(data, len, out);
return EL_STR((char*)out);
}
el_val_t sha256_hex(el_val_t input) {
const char* s = EL_CSTR(input);
size_t n = el_input_len(s);
unsigned char digest[32];
el_sha256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
return el_hex_encode(digest, 32);
}
el_val_t sha256_bytes(el_val_t input) {
const char* s = EL_CSTR(input);
size_t n = el_input_len(s);
return el_sha256_bytes_n((const unsigned char*)(s ? s : ""), n);
}
el_val_t hmac_sha256_hex(el_val_t key, el_val_t message) {
const char* k = EL_CSTR(key);
const char* m = EL_CSTR(message);
size_t kn = el_input_len(k);
size_t mn = el_input_len(m);
unsigned char mac[32];
el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
(const unsigned char*)(m ? m : ""), mn,
mac);
return el_hex_encode(mac, 32);
}
el_val_t hmac_sha256_bytes(el_val_t key, el_val_t message) {
const char* k = EL_CSTR(key);
const char* m = EL_CSTR(message);
size_t kn = el_input_len(k);
size_t mn = el_input_len(m);
unsigned char* out = el_bin_alloc(32);
el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
(const unsigned char*)(m ? m : ""), mn,
out);
return EL_STR((char*)out);
}
el_val_t base64_encode(el_val_t input) {
const char* s = EL_CSTR(input);
size_t n = el_input_len(s);
return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/0);
}
el_val_t base64url_encode(el_val_t input) {
const char* s = EL_CSTR(input);
size_t n = el_input_len(s);
return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/1);
}
el_val_t base64_decode(el_val_t input) {
return el_base64_decode_any(EL_CSTR(input));
}
el_val_t base64url_decode(el_val_t input) {
return el_base64_decode_any(EL_CSTR(input));
}
/* ── Post-quantum cryptography (liboqs + OpenSSL) ───────────────────────────
*
* Algorithm choices (per CNSA 2.0 / NIST PQ guidance, as of 2024):
* Signatures: CRYSTALS-Dilithium-3 (NIST security level 3, balanced)
* KEM: CRYSTALS-Kyber-768 (NIST security level 3)
* Hash: SHA3-256 (Keccak) (PQ-aware protocols favour SHA3 over SHA2)
* Hybrid: X25519 || Kyber-768, combined via HKDF-SHA256
*
* Why hybrid: Kyber is new. X25519 has 20+ years of analysis. Hybridizing
* preserves classical security if Kyber falls to a future cryptanalytic
* advance, and preserves PQ security if X25519 falls to a quantum adversary.
* "Recordable now, decryptable later" already threatens long-lived classical
* key exchange — the only safe move for keys protecting durable doctrine
* (CGI lineage, KindredGrants, Principal-CGI covenants) is to encapsulate
* with PQ today, even if the classical leg is what the wire shows.
*
* Compile-time detection: when <oqs/oqs.h> is unavailable the pq_* functions
* compile to stubs that return a JSON error envelope. SHA3-256 stays
* available regardless (it's implemented inline, no liboqs dep). This lets
* the runtime build cleanly on dev machines without liboqs while production
* gets the full PQ stack. */
/* ─── SHA3-256 (Keccak, FIPS 202) ────────────────────────────────────────────
* Inline reference implementation. ~120 LoC, no external dependency.
* rate=1088 bits, capacity=512 bits, output=256 bits, padding=0x06. */
static const uint64_t el_keccak_rc[24] = {
0x0000000000000001ULL, 0x0000000000008082ULL, 0x800000000000808aULL,
0x8000000080008000ULL, 0x000000000000808bULL, 0x0000000080000001ULL,
0x8000000080008081ULL, 0x8000000000008009ULL, 0x000000000000008aULL,
0x0000000000000088ULL, 0x0000000080008009ULL, 0x000000008000000aULL,
0x000000008000808bULL, 0x800000000000008bULL, 0x8000000000008089ULL,
0x8000000000008003ULL, 0x8000000000008002ULL, 0x8000000000000080ULL,
0x000000000000800aULL, 0x800000008000000aULL, 0x8000000080008081ULL,
0x8000000000008080ULL, 0x0000000080000001ULL, 0x8000000080008008ULL
};
static const unsigned el_keccak_rho[24] = {
1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14,
27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44
};
static const unsigned el_keccak_pi[24] = {
10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4,
15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1
};
#define EL_ROTL64(x, n) (((x) << (n)) | ((x) >> (64 - (n))))
static void el_keccak_f1600(uint64_t s[25]) {
for (int round = 0; round < 24; ++round) {
uint64_t bc[5], t;
for (int i = 0; i < 5; ++i)
bc[i] = s[i] ^ s[i+5] ^ s[i+10] ^ s[i+15] ^ s[i+20];
for (int i = 0; i < 5; ++i) {
t = bc[(i+4) % 5] ^ EL_ROTL64(bc[(i+1) % 5], 1);
for (int j = 0; j < 25; j += 5) s[j+i] ^= t;
}
t = s[1];
for (int i = 0; i < 24; ++i) {
int j = el_keccak_pi[i];
bc[0] = s[j];
s[j] = EL_ROTL64(t, el_keccak_rho[i]);
t = bc[0];
}
for (int j = 0; j < 25; j += 5) {
for (int i = 0; i < 5; ++i) bc[i] = s[j+i];
for (int i = 0; i < 5; ++i)
s[j+i] = bc[i] ^ ((~bc[(i+1) % 5]) & bc[(i+2) % 5]);
}
s[0] ^= el_keccak_rc[round];
}
}
static void el_sha3_256_oneshot(const unsigned char* data, size_t len,
unsigned char out[32]) {
uint64_t st[25] = {0};
unsigned char* sb = (unsigned char*)st;
const size_t rate = 136; /* 1088 bits / 8 */
size_t i = 0;
while (len - i >= rate) {
for (size_t k = 0; k < rate; ++k) sb[k] ^= data[i + k];
el_keccak_f1600(st);
i += rate;
}
size_t rem = len - i;
for (size_t k = 0; k < rem; ++k) sb[k] ^= data[i + k];
sb[rem] ^= 0x06; /* SHA3 domain-separation byte */
sb[rate - 1] ^= 0x80; /* final-block padding bit (high bit of last byte) */
el_keccak_f1600(st);
memcpy(out, sb, 32);
}
el_val_t sha3_256_hex(el_val_t input) {
const char* s = EL_CSTR(input);
size_t n = el_input_len(s);
unsigned char digest[32];
el_sha3_256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
return el_hex_encode(digest, 32);
}
/* ─── Hex decode helper ─────────────────────────────────────────────────────
* Returns a length-tagged binary buffer (so embedded NULs survive); on
* odd-length / invalid input returns NULL with *out_len = 0. Caller is
* responsible for emitting the error envelope. */
static int el_hex_nibble(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
__attribute__((unused))
static unsigned char* el_hex_decode(const char* s, size_t* out_len) {
*out_len = 0;
if (!s) return NULL;
size_t n = strlen(s);
if (n & 1) return NULL;
size_t blen = n / 2;
unsigned char* out = el_bin_alloc(blen);
for (size_t i = 0; i < blen; ++i) {
int hi = el_hex_nibble(s[i*2]);
int lo = el_hex_nibble(s[i*2 + 1]);
if (hi < 0 || lo < 0) return NULL;
out[i] = (unsigned char)((hi << 4) | lo);
}
*out_len = blen;
return out;
}
/* JSON error envelope reused across all PQ entry points. */
static el_val_t pq_error(const char* msg) {
return http_error_json(msg);
}
#if __has_include(<oqs/oqs.h>)
#include <oqs/oqs.h>
#define EL_HAVE_LIBOQS 1
#else
#define EL_HAVE_LIBOQS 0
#endif
#if EL_HAVE_LIBOQS && __has_include(<openssl/evp.h>)
#include <openssl/evp.h>
#define EL_HAVE_OPENSSL 1
#else
#define EL_HAVE_OPENSSL 0
#endif
#if !EL_HAVE_LIBOQS
/* ─── Stubs (liboqs unavailable) ───────────────────────────────────────────
* Each entry point returns the same JSON error so callers can inspect a
* single canonical "missing primitive" string. pq_verify is the lone
* exception — verifying without liboqs simply means "not verified", so
* returning Bool false (0) keeps the type contract intact. */
#define EL_PQ_NO_LIB "liboqs not linked, post-quantum primitives unavailable"
el_val_t pq_keygen_signature(void) { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_sign(el_val_t sk, el_val_t msg) { (void)sk; (void)msg; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_verify(el_val_t pk, el_val_t msg, el_val_t sig) { (void)pk; (void)msg; (void)sig; return EL_INT(0); }
el_val_t pq_kem_keygen(void) { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_encaps(el_val_t pk) { (void)pk; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_decaps(el_val_t sk, el_val_t ct) { (void)sk; (void)ct; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_keygen(void) { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_handshake(el_val_t pub) { (void)pub; return pq_error(EL_PQ_NO_LIB); }
#else /* EL_HAVE_LIBOQS */
/* ─── Dilithium-3 / ML-DSA-65 signatures ────────────────────────────────
*
* NIST FIPS 204 standardized CRYSTALS-Dilithium as ML-DSA. ML-DSA-65 is the
* FIPS form of what we historically called Dilithium-3 — same algorithm
* family, same security level, identical key/sig sizes, but with a couple
* of standardization-driven tweaks (e.g. domain separation in the message
* binding). liboqs 0.12+ exposes both names; 0.15+ retired the legacy
* "Dilithium" constants in favour of "ML-DSA". We prefer ML-DSA-65 if the
* header advertises it, fall back to Dilithium-3 otherwise. Anything
* already signed with the older constant remains verifiable against that
* same constant — callers should pin the algorithm via the OQS_SIG handle's
* method_name field if they need to interoperate with archival signatures. */
#if defined(OQS_SIG_alg_ml_dsa_65)
# define EL_DILITHIUM_ALG OQS_SIG_alg_ml_dsa_65
#elif defined(OQS_SIG_alg_dilithium_3)
# define EL_DILITHIUM_ALG OQS_SIG_alg_dilithium_3
#else
# define EL_DILITHIUM_ALG "ML-DSA-65" /* string fallback; runtime probe catches misconfig */
#endif
el_val_t pq_keygen_signature(void) {
OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
unsigned char* pk = (unsigned char*)malloc(sig->length_public_key);
unsigned char* sk = (unsigned char*)malloc(sig->length_secret_key);
if (!pk || !sk) { free(pk); free(sk); OQS_SIG_free(sig); return pq_error("oom"); }
if (OQS_SIG_keypair(sig, pk, sk) != OQS_SUCCESS) {
free(pk); free(sk); OQS_SIG_free(sig);
return pq_error("dilithium-3 keypair generation failed");
}
el_val_t pk_hex = el_hex_encode(pk, sig->length_public_key);
el_val_t sk_hex = el_hex_encode(sk, sig->length_secret_key);
OQS_MEM_secure_free(sk, sig->length_secret_key);
free(pk);
const char* pks = EL_CSTR(pk_hex);
const char* sks = EL_CSTR(sk_hex);
char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
OQS_SIG_free(sig);
return el_wrap_str(buf);
}
el_val_t pq_sign(el_val_t secret_key_hex, el_val_t message) {
size_t sk_len = 0;
unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
if (!sk) return pq_error("invalid hex in secret_key");
OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
if (sk_len != sig->length_secret_key) {
OQS_SIG_free(sig);
return pq_error("secret_key length mismatch for dilithium-3");
}
const char* msg = EL_CSTR(message);
size_t msg_len = el_input_len(msg);
unsigned char* signature = (unsigned char*)malloc(sig->length_signature);
size_t signature_len = sig->length_signature;
if (!signature) { OQS_SIG_free(sig); return pq_error("oom"); }
if (OQS_SIG_sign(sig, signature, &signature_len,
(const unsigned char*)(msg ? msg : ""), msg_len, sk) != OQS_SUCCESS) {
free(signature); OQS_SIG_free(sig);
return pq_error("dilithium-3 sign failed");
}
el_val_t sig_hex = el_hex_encode(signature, signature_len);
free(signature); OQS_SIG_free(sig);
return sig_hex;
}
el_val_t pq_verify(el_val_t public_key_hex, el_val_t message, el_val_t signature_hex) {
size_t pk_len = 0, sig_len = 0;
unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
unsigned char* signature = el_hex_decode(EL_CSTR(signature_hex), &sig_len);
if (!pk || !signature) return EL_INT(0);
OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
if (!sig) return EL_INT(0);
if (pk_len != sig->length_public_key) { OQS_SIG_free(sig); return EL_INT(0); }
const char* msg = EL_CSTR(message);
size_t msg_len = el_input_len(msg);
OQS_STATUS rc = OQS_SIG_verify(sig,
(const unsigned char*)(msg ? msg : ""), msg_len,
signature, sig_len, pk);
OQS_SIG_free(sig);
return (rc == OQS_SUCCESS) ? EL_INT(1) : EL_INT(0);
}
/* ─── Kyber-768 / ML-KEM-768 KEM ────────────────────────────────────────
*
* NIST FIPS 203 standardized CRYSTALS-Kyber as ML-KEM. ML-KEM-768 is the
* FIPS form of what we historically called Kyber-768. Same situation as
* Dilithium → ML-DSA: prefer the standardized constant, fall back to the
* legacy name. liboqs 0.15.0 still exposes OQS_KEM_alg_kyber_768; the
* algorithm is identical at the wire level to ML-KEM-768 except for FIPS
* domain-separation tweaks, so the two ciphertexts/keys are NOT
* cross-compatible. Pin the constant for archival material. */
#if defined(OQS_KEM_alg_ml_kem_768)
# define EL_KYBER_ALG OQS_KEM_alg_ml_kem_768
#elif defined(OQS_KEM_alg_kyber_768)
# define EL_KYBER_ALG OQS_KEM_alg_kyber_768
#else
# define EL_KYBER_ALG "ML-KEM-768"
#endif
el_val_t pq_kem_keygen(void) {
OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
unsigned char* pk = (unsigned char*)malloc(kem->length_public_key);
unsigned char* sk = (unsigned char*)malloc(kem->length_secret_key);
if (!pk || !sk) { free(pk); free(sk); OQS_KEM_free(kem); return pq_error("oom"); }
if (OQS_KEM_keypair(kem, pk, sk) != OQS_SUCCESS) {
free(pk); free(sk); OQS_KEM_free(kem);
return pq_error("kyber-768 keypair generation failed");
}
el_val_t pk_hex = el_hex_encode(pk, kem->length_public_key);
el_val_t sk_hex = el_hex_encode(sk, kem->length_secret_key);
OQS_MEM_secure_free(sk, kem->length_secret_key);
free(pk);
const char* pks = EL_CSTR(pk_hex);
const char* sks = EL_CSTR(sk_hex);
char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
OQS_KEM_free(kem);
return el_wrap_str(buf);
}
el_val_t pq_kem_encaps(el_val_t public_key_hex) {
size_t pk_len = 0;
unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
if (!pk) return pq_error("invalid hex in public_key");
OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
if (pk_len != kem->length_public_key) {
OQS_KEM_free(kem);
return pq_error("public_key length mismatch for kyber-768");
}
unsigned char* ct = (unsigned char*)malloc(kem->length_ciphertext);
unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
if (!ct || !ss) { free(ct); free(ss); OQS_KEM_free(kem); return pq_error("oom"); }
if (OQS_KEM_encaps(kem, ct, ss, pk) != OQS_SUCCESS) {
free(ct); free(ss); OQS_KEM_free(kem);
return pq_error("kyber-768 encapsulation failed");
}
el_val_t ct_hex = el_hex_encode(ct, kem->length_ciphertext);
el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
free(ct);
OQS_MEM_secure_free(ss, kem->length_shared_secret);
const char* cts = EL_CSTR(ct_hex);
const char* sss = EL_CSTR(ss_hex);
char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
OQS_KEM_free(kem);
return el_wrap_str(buf);
}
el_val_t pq_kem_decaps(el_val_t secret_key_hex, el_val_t ciphertext_hex) {
size_t sk_len = 0, ct_len = 0;
unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
unsigned char* ct = el_hex_decode(EL_CSTR(ciphertext_hex), &ct_len);
if (!sk || !ct) return pq_error("invalid hex in inputs");
OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
if (sk_len != kem->length_secret_key || ct_len != kem->length_ciphertext) {
OQS_KEM_free(kem);
return pq_error("input length mismatch for kyber-768");
}
unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
if (!ss) { OQS_KEM_free(kem); return pq_error("oom"); }
/* Kyber is IND-CCA via Fujisaki-Okamoto: decaps always returns *some*
* shared_secret even on tampered ciphertext (an implicit-rejection value
* derived from sk). Protocols MUST confirm the shared_secret matches via
* a subsequent step (e.g. AEAD tag, key-confirmation MAC) — do not
* assume decaps success implies authenticity. */
if (OQS_KEM_decaps(kem, ss, ct, sk) != OQS_SUCCESS) {
free(ss); OQS_KEM_free(kem);
return pq_error("kyber-768 decapsulation failed");
}
el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
OQS_MEM_secure_free(ss, kem->length_shared_secret);
OQS_KEM_free(kem);
return ss_hex;
}
/* ─── Hybrid handshake (X25519 + Kyber-768, HKDF-SHA256 combined) ─────── */
#if !EL_HAVE_OPENSSL
el_val_t pq_hybrid_keygen(void) {
return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}
el_val_t pq_hybrid_handshake(el_val_t pub) {
(void)pub;
return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}
#else /* EL_HAVE_OPENSSL */
/* HKDF-SHA256 (RFC 5869) — Extract+Expand. Reuses the inline HMAC-SHA256
* already in this file. Empty salt → 32 zero bytes per the RFC. */
static void el_hkdf_sha256(const unsigned char* salt, size_t salt_len,
const unsigned char* ikm, size_t ikm_len,
const unsigned char* info, size_t info_len,
unsigned char* out, size_t out_len) {
unsigned char zero_salt[32] = {0};
if (salt_len == 0) { salt = zero_salt; salt_len = 32; }
unsigned char prk[32];
el_hmac_sha256(salt, salt_len, ikm, ikm_len, prk);
unsigned char t[32];
size_t produced = 0;
unsigned char counter = 1;
unsigned char* buf = (unsigned char*)malloc(32 + info_len + 1);
if (!buf) { fputs("el_runtime: hkdf oom\n", stderr); return; }
while (produced < out_len) {
size_t off = 0;
if (counter > 1) { memcpy(buf, t, 32); off = 32; }
if (info && info_len) { memcpy(buf + off, info, info_len); off += info_len; }
buf[off++] = counter;
el_hmac_sha256(prk, 32, buf, off, t);
size_t chunk = (out_len - produced > 32) ? 32 : (out_len - produced);
memcpy(out + produced, t, chunk);
produced += chunk;
counter++;
}
free(buf);
}
/* X25519 keygen via OpenSSL EVP. Returns 1 on success.
* Fills pk[32] and sk[32] (raw X25519 byte strings, no DER wrapper). */
static int el_x25519_keygen(unsigned char pk[32], unsigned char sk[32]) {
EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
if (!pctx) return 0;
if (EVP_PKEY_keygen_init(pctx) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
EVP_PKEY* key = NULL;
if (EVP_PKEY_keygen(pctx, &key) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
EVP_PKEY_CTX_free(pctx);
size_t plen = 32, slen = 32;
if (EVP_PKEY_get_raw_public_key (key, pk, &plen) != 1 || plen != 32) {
EVP_PKEY_free(key); return 0;
}
if (EVP_PKEY_get_raw_private_key(key, sk, &slen) != 1 || slen != 32) {
EVP_PKEY_free(key); return 0;
}
EVP_PKEY_free(key);
return 1;
}
/* X25519 ECDH: derive 32-byte shared secret from local sk and remote pk. */
static int el_x25519_derive(const unsigned char sk[32], const unsigned char rpk[32],
unsigned char ss[32]) {
EVP_PKEY* my = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, sk, 32);
EVP_PKEY* rem = EVP_PKEY_new_raw_public_key (EVP_PKEY_X25519, NULL, rpk, 32);
if (!my || !rem) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
EVP_PKEY_CTX* dctx = EVP_PKEY_CTX_new(my, NULL);
if (!dctx) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
int ok = 0;
size_t out_len = 32;
if (EVP_PKEY_derive_init(dctx) == 1 &&
EVP_PKEY_derive_set_peer(dctx, rem) == 1 &&
EVP_PKEY_derive(dctx, ss, &out_len) == 1 &&
out_len == 32) ok = 1;
EVP_PKEY_CTX_free(dctx);
EVP_PKEY_free(my);
EVP_PKEY_free(rem);
return ok;
}
/* Hybrid wire layout (binary form, before hex encode):
* public_key = x25519_pub (32) || kyber_pub (1184) → 1216 bytes
* secret_key = x25519_sec (32) || kyber_sec (2400) → 2432 bytes
* ciphertext = ephem_x25519_pub (32) || kyber_ct (1088) → 1120 bytes
* shared_secret = HKDF-SHA256(x25519_ss || kyber_ss, info="el-pq-hybrid-v1", 32 bytes)
* The keygen result also exposes the four component hex fields for callers
* that prefer to handle the legs independently. */
el_val_t pq_hybrid_keygen(void) {
OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
unsigned char xpk[32], xsk[32];
if (!el_x25519_keygen(xpk, xsk)) {
OQS_KEM_free(kem);
return pq_error("X25519 keygen failed");
}
unsigned char* kpk = (unsigned char*)malloc(kem->length_public_key);
unsigned char* ksk = (unsigned char*)malloc(kem->length_secret_key);
if (!kpk || !ksk) { free(kpk); free(ksk); OQS_KEM_free(kem); return pq_error("oom"); }
if (OQS_KEM_keypair(kem, kpk, ksk) != OQS_SUCCESS) {
free(kpk); free(ksk); OQS_KEM_free(kem);
return pq_error("kyber-768 keypair generation failed");
}
size_t pub_len = 32 + kem->length_public_key;
size_t sec_len = 32 + kem->length_secret_key;
unsigned char* pub_buf = (unsigned char*)malloc(pub_len);
unsigned char* sec_buf = (unsigned char*)malloc(sec_len);
if (!pub_buf || !sec_buf) {
free(pub_buf); free(sec_buf); free(kpk);
OQS_MEM_secure_free(ksk, kem->length_secret_key);
OQS_KEM_free(kem); return pq_error("oom");
}
memcpy(pub_buf, xpk, 32); memcpy(pub_buf + 32, kpk, kem->length_public_key);
memcpy(sec_buf, xsk, 32); memcpy(sec_buf + 32, ksk, kem->length_secret_key);
el_val_t x_pub_hex = el_hex_encode(xpk, 32);
el_val_t x_sec_hex = el_hex_encode(xsk, 32);
el_val_t k_pub_hex = el_hex_encode(kpk, kem->length_public_key);
el_val_t k_sec_hex = el_hex_encode(ksk, kem->length_secret_key);
el_val_t pub_hex = el_hex_encode(pub_buf, pub_len);
el_val_t sec_hex = el_hex_encode(sec_buf, sec_len);
OQS_MEM_secure_free(ksk, kem->length_secret_key);
free(kpk); free(pub_buf); free(sec_buf);
OQS_KEM_free(kem);
memset(xsk, 0, 32); /* best-effort wipe of stack copy */
const char* xph = EL_CSTR(x_pub_hex);
const char* xsh = EL_CSTR(x_sec_hex);
const char* kph = EL_CSTR(k_pub_hex);
const char* ksh = EL_CSTR(k_sec_hex);
const char* pubh = EL_CSTR(pub_hex);
const char* sech = EL_CSTR(sec_hex);
char* buf = el_strbuf(strlen(xph) + strlen(xsh) + strlen(kph) + strlen(ksh)
+ strlen(pubh) + strlen(sech) + 256);
sprintf(buf,
"{\"x25519_pub\":\"%s\",\"x25519_sec\":\"%s\","
"\"kyber_pub\":\"%s\",\"kyber_sec\":\"%s\","
"\"public_key\":\"%s\",\"secret_key\":\"%s\"}",
xph, xsh, kph, ksh, pubh, sech);
return el_wrap_str(buf);
}
/* Initiator-side handshake. Caller supplies the responder's combined public
* key (x25519_pub || kyber_pub, hex-encoded). The runtime:
* 1. Generates an ephemeral X25519 keypair, runs ECDH against the
* responder's static x25519_pub.
* 2. Runs Kyber-768 encaps against the responder's kyber_pub → kyber_ct,
* kyber_ss.
* 3. Combined shared = HKDF-SHA256(salt="", ikm = x25519_ss || kyber_ss,
* info = "el-pq-hybrid-v1", L = 32).
* 4. Returns combined ciphertext (= ephemeral_x25519_pub || kyber_ct) and
* the derived shared_secret.
*
* Responder side composition (intentionally not a separate runtime fn —
* trivial to express in El given pq_kem_decaps + a future x25519_derive
* primitive): split the ciphertext into ephem_xpk (32) and kyber_ct, run
* X25519(static_xsk, ephem_xpk) and pq_kem_decaps(static_kyber_sk, kyber_ct),
* then HKDF-SHA256 with the same salt/info to recover the same shared_secret.
* If a separate x25519 entry point becomes valuable, add `pq_hybrid_open`
* here taking (secret_key_combined, ciphertext_combined). */
el_val_t pq_hybrid_handshake(el_val_t remote_pub_combined) {
size_t pub_len = 0;
unsigned char* rpub = el_hex_decode(EL_CSTR(remote_pub_combined), &pub_len);
if (!rpub) return pq_error("invalid hex in remote_pub_combined");
OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
if (pub_len != 32 + kem->length_public_key) {
OQS_KEM_free(kem);
return pq_error("remote_pub_combined length mismatch (expected x25519_pub || kyber_pub)");
}
unsigned char e_xpk[32], e_xsk[32], x_ss[32];
if (!el_x25519_keygen(e_xpk, e_xsk)) {
OQS_KEM_free(kem);
return pq_error("X25519 ephemeral keygen failed");
}
if (!el_x25519_derive(e_xsk, rpub, x_ss)) {
memset(e_xsk, 0, 32);
OQS_KEM_free(kem);
return pq_error("X25519 derive failed");
}
memset(e_xsk, 0, 32); /* ephemeral; not needed after derive */
unsigned char* k_ct = (unsigned char*)malloc(kem->length_ciphertext);
unsigned char* k_ss = (unsigned char*)malloc(kem->length_shared_secret);
if (!k_ct || !k_ss) {
free(k_ct); free(k_ss); OQS_KEM_free(kem);
return pq_error("oom");
}
if (OQS_KEM_encaps(kem, k_ct, k_ss, rpub + 32) != OQS_SUCCESS) {
free(k_ct); free(k_ss); OQS_KEM_free(kem);
return pq_error("kyber-768 encapsulation failed");
}
/* HKDF combine: ikm = x_ss || k_ss. */
size_t ikm_len = 32 + kem->length_shared_secret;
unsigned char* ikm = (unsigned char*)malloc(ikm_len);
if (!ikm) {
free(k_ct); OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
OQS_KEM_free(kem);
return pq_error("oom");
}
memcpy(ikm, x_ss, 32);
memcpy(ikm + 32, k_ss, kem->length_shared_secret);
unsigned char combined[32];
static const char info_str[] = "el-pq-hybrid-v1";
el_hkdf_sha256(NULL, 0, ikm, ikm_len,
(const unsigned char*)info_str, sizeof(info_str) - 1,
combined, 32);
memset(x_ss, 0, 32);
OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
OQS_MEM_secure_free(ikm, ikm_len);
/* Combined ciphertext = ephemeral_x25519_pub || kyber_ct. */
size_t ct_len = 32 + kem->length_ciphertext;
unsigned char* combined_ct = (unsigned char*)malloc(ct_len);
if (!combined_ct) { free(k_ct); OQS_KEM_free(kem); return pq_error("oom"); }
memcpy(combined_ct, e_xpk, 32);
memcpy(combined_ct + 32, k_ct, kem->length_ciphertext);
free(k_ct);
OQS_KEM_free(kem);
el_val_t ct_hex = el_hex_encode(combined_ct, ct_len);
el_val_t ss_hex = el_hex_encode(combined, 32);
free(combined_ct);
memset(combined, 0, 32);
const char* cts = EL_CSTR(ct_hex);
const char* sss = EL_CSTR(ss_hex);
char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
return el_wrap_str(buf);
}
#endif /* EL_HAVE_OPENSSL */
#endif /* EL_HAVE_LIBOQS */
/* ─── AEAD: AES-256-GCM ────────────────────────────────────────────────────
*
* Symmetric authenticated encryption used to wrap envelopes once a shared
* secret has been derived from the KEM (Kyber-768 / hybrid). The El surface
* is intentionally narrow:
*
* aead_encrypt(key_hex, plaintext)
* → {"nonce":"<24 hex>","ciphertext":"<...hex including 16-byte tag>"}
*
* aead_decrypt(key_hex, nonce_hex, ciphertext_hex)
* → plaintext String, or "" on auth failure / malformed input
*
* Conventions:
* - key_hex must decode to exactly 32 bytes (AES-256). Callers that hold
* a longer KEM shared_secret should normalize via SHA3-256(ss) → 32 bytes
* before passing it in. (Kyber-768's shared_secret is already 32 bytes,
* but keeping this contract explicit lets the El side be agnostic.)
* - nonce is a fresh 12-byte random value drawn from the OS CSPRNG. Caller
* never picks the nonce — eliminates the GCM nonce-reuse footgun entirely.
* - tag is the standard 16 bytes, appended to ciphertext per RFC 5116.
* `ciphertext` field is therefore (plaintext_len + 16) bytes, hex-encoded.
* - No associated data (AAD). If we later need bound metadata, add a
* length-prefixed AAD argument and bump the envelope version tag.
*
* Failure mode:
* aead_encrypt returns http_error_json(...) on input/system failure.
* aead_decrypt returns the empty string on ANY failure (including auth-tag
* mismatch). Callers MUST check for "" before using the result. */
#if !__has_include(<openssl/evp.h>)
el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext) {
(void)key_hex; (void)plaintext;
return http_error_json("aead_encrypt requires OpenSSL (libcrypto); rebuild with -lcrypto");
}
el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex) {
(void)key_hex; (void)nonce_hex; (void)ciphertext_hex;
return el_wrap_str(el_strdup(""));
}
#else /* OpenSSL available */
#include <openssl/evp.h>
#include <openssl/rand.h>
el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext) {
size_t key_len = 0;
unsigned char* key = el_hex_decode(EL_CSTR(key_hex), &key_len);
if (!key) return http_error_json("invalid hex in key");
if (key_len != 32) return http_error_json("aead key must be 32 bytes (64 hex chars) for AES-256-GCM");
const char* pt = EL_CSTR(plaintext);
size_t pt_len = el_input_len(pt);
if (!pt) pt = "";
unsigned char nonce[12];
if (RAND_bytes(nonce, 12) != 1) return http_error_json("OS CSPRNG failed (RAND_bytes)");
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx) return http_error_json("EVP_CIPHER_CTX_new failed");
if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) {
EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm init failed");
}
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1) {
EVP_CIPHER_CTX_free(ctx); return http_error_json("set ivlen failed");
}
if (EVP_EncryptInit_ex(ctx, NULL, NULL, key, nonce) != 1) {
EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm key/iv init failed");
}
/* GCM ciphertext is the same length as plaintext; we append a 16-byte
* authentication tag for AEAD semantics. Allocate plaintext_len + 16. */
unsigned char* ct = (unsigned char*)malloc(pt_len + 16);
if (!ct) { EVP_CIPHER_CTX_free(ctx); return http_error_json("oom"); }
int outlen = 0, total = 0;
if (EVP_EncryptUpdate(ctx, ct, &outlen, (const unsigned char*)pt, (int)pt_len) != 1) {
free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm update failed");
}
total += outlen;
if (EVP_EncryptFinal_ex(ctx, ct + total, &outlen) != 1) {
free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm final failed");
}
total += outlen;
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, ct + total) != 1) {
free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm get tag failed");
}
EVP_CIPHER_CTX_free(ctx);
el_val_t nonce_hex_v = el_hex_encode(nonce, 12);
el_val_t ct_hex_v = el_hex_encode(ct, (size_t)total + 16);
free(ct);
const char* nh = EL_CSTR(nonce_hex_v);
const char* ch = EL_CSTR(ct_hex_v);
char* buf = el_strbuf(strlen(nh) + strlen(ch) + 48);
sprintf(buf, "{\"nonce\":\"%s\",\"ciphertext\":\"%s\"}", nh, ch);
return el_wrap_str(buf);
}
el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex) {
size_t key_len = 0, nonce_len = 0, ct_len = 0;
unsigned char* key = el_hex_decode(EL_CSTR(key_hex), &key_len);
unsigned char* nonce = el_hex_decode(EL_CSTR(nonce_hex), &nonce_len);
unsigned char* ct = el_hex_decode(EL_CSTR(ciphertext_hex), &ct_len);
if (!key || !nonce || !ct) return el_wrap_str(el_strdup(""));
if (key_len != 32 || nonce_len != 12) return el_wrap_str(el_strdup(""));
if (ct_len < 16) return el_wrap_str(el_strdup(""));
size_t body_len = ct_len - 16;
const unsigned char* tag = ct + body_len;
EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
if (!ctx) return el_wrap_str(el_strdup(""));
if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1 ||
EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1 ||
EVP_DecryptInit_ex(ctx, NULL, NULL, key, nonce) != 1) {
EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
}
unsigned char* pt = (unsigned char*)malloc(body_len + 1);
if (!pt) { EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup("")); }
int outlen = 0, total = 0;
if (EVP_DecryptUpdate(ctx, pt, &outlen, ct, (int)body_len) != 1) {
free(pt); EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
}
total += outlen;
/* Set expected tag before final — GCM's final step is where auth happens. */
if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, (void*)tag) != 1) {
free(pt); EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
}
int rc = EVP_DecryptFinal_ex(ctx, pt + total, &outlen);
EVP_CIPHER_CTX_free(ctx);
if (rc != 1) {
/* Auth failure or padding/length mismatch. Return empty so callers
* cannot accidentally treat tampered ciphertext as a valid message. */
free(pt);
return el_wrap_str(el_strdup(""));
}
total += outlen;
pt[total] = '\0';
/* Copy into the el arena so the caller-visible string outlives this fn. */
char* out = el_strbuf((size_t)total);
memcpy(out, pt, (size_t)total);
out[total] = '\0';
free(pt);
return el_wrap_str(out);
}
#endif /* __has_include(<openssl/evp.h>) */
#ifdef HAVE_CURL
/* ────────────────────────────────────────────────────────────────────────────
* OTLP/HTTP observability — logs, traces, metrics
*
* Design goals:
* - Zero blocking on the request path. Producers append to in-memory
* ring buffers; a single worker thread flushes to the OTLP endpoint.
* - Drop-on-failure semantics. If the endpoint is unreachable or slow,
* we drop telemetry rather than back-pressure into the request handler.
* - Best-effort serialization. Each record is pre-serialized as JSON when
* the El program calls the primitive; the worker just batches.
* - Configuration via env vars:
* OTLP_ENDPOINT e.g. https://alloy.neuralplatform.ai:4318
* OTEL_SERVICE_NAME e.g. neuron-web (default: argv[0] basename)
* OTEL_SERVICE_VERSION (default: "0.0.0")
* OTEL_RESOURCE_ATTRS comma-sep k=v pairs (optional)
*
* Wire format: OTLP/HTTP JSON. Three endpoints:
* POST {endpoint}/v1/logs — log records
* POST {endpoint}/v1/traces — spans
* POST {endpoint}/v1/metrics — counter/gauge points
*
* El programs see four primitives:
* trace_span_start(name) -> SpanHandle (just a string id)
* trace_span_end(handle) (computes duration, queues)
* emit_log(level, msg, fields_json) (queues a log record)
* emit_metric(name, value, tags_json) (queues a counter increment)
* ────────────────────────────────────────────────────────────────────────────
*/
#define OTLP_BUF_CAP 4096 /* per-buffer ring size */
#define OTLP_FLUSH_MS 2000 /* flush every 2s */
#define OTLP_BATCH_MAX 200 /* up to 200 records per POST */
typedef struct {
char* data; /* malloc'd JSON fragment for this record */
} OtlpRec;
typedef struct {
OtlpRec ring[OTLP_BUF_CAP];
size_t head; /* next write slot */
size_t tail; /* next read slot */
pthread_mutex_t mu;
} OtlpQueue;
static OtlpQueue _otlp_logs = { .mu = PTHREAD_MUTEX_INITIALIZER };
static OtlpQueue _otlp_traces = { .mu = PTHREAD_MUTEX_INITIALIZER };
static OtlpQueue _otlp_metrics = { .mu = PTHREAD_MUTEX_INITIALIZER };
static char* _otlp_endpoint = NULL; /* e.g. https://alloy.neuralplatform.ai:4318 */
static char* _otlp_service_name = NULL;
static char* _otlp_service_version = NULL;
static int _otlp_initialized = 0;
static pthread_t _otlp_worker_thread;
/* enqueue — returns 1 if accepted, 0 if dropped (full buffer or no endpoint) */
static int otlp_enqueue(OtlpQueue* q, const char* json) {
if (!_otlp_endpoint || !json) return 0;
pthread_mutex_lock(&q->mu);
size_t next_head = (q->head + 1) % OTLP_BUF_CAP;
if (next_head == q->tail) {
/* buffer full — drop oldest */
free(q->ring[q->tail].data);
q->ring[q->tail].data = NULL;
q->tail = (q->tail + 1) % OTLP_BUF_CAP;
}
q->ring[q->head].data = strdup(json);
q->head = next_head;
pthread_mutex_unlock(&q->mu);
return 1;
}
/* drain — copies up to OTLP_BATCH_MAX items into a comma-joined string,
* caller must free the result. Returns NULL if queue is empty. */
static char* otlp_drain(OtlpQueue* q) {
pthread_mutex_lock(&q->mu);
if (q->head == q->tail) { pthread_mutex_unlock(&q->mu); return NULL; }
/* compute total length */
size_t total = 0, count = 0;
size_t i = q->tail;
while (i != q->head && count < OTLP_BATCH_MAX) {
if (q->ring[i].data) total += strlen(q->ring[i].data) + 1; /* +1 for comma */
i = (i + 1) % OTLP_BUF_CAP;
count++;
}
char* out = malloc(total + 4);
if (!out) { pthread_mutex_unlock(&q->mu); return NULL; }
out[0] = '\0';
size_t off = 0;
i = q->tail;
count = 0;
while (i != q->head && count < OTLP_BATCH_MAX) {
if (q->ring[i].data) {
size_t l = strlen(q->ring[i].data);
if (off > 0) { out[off++] = ','; }
memcpy(out + off, q->ring[i].data, l);
off += l;
free(q->ring[i].data);
q->ring[i].data = NULL;
}
i = (i + 1) % OTLP_BUF_CAP;
count++;
}
out[off] = '\0';
q->tail = i;
pthread_mutex_unlock(&q->mu);
return out;
}
/* Build resource block once (service.name, service.version, host.name) */
static char* otlp_resource_block(void) {
static char cached[1024];
static int built = 0;
if (built) return cached;
char host[256] = "unknown";
gethostname(host, sizeof(host) - 1);
snprintf(cached, sizeof(cached),
"{\"attributes\":["
"{\"key\":\"service.name\",\"value\":{\"stringValue\":\"%s\"}},"
"{\"key\":\"service.version\",\"value\":{\"stringValue\":\"%s\"}},"
"{\"key\":\"host.name\",\"value\":{\"stringValue\":\"%s\"}}"
"]}",
_otlp_service_name ? _otlp_service_name : "el-app",
_otlp_service_version ? _otlp_service_version : "0.0.0",
host);
built = 1;
return cached;
}
/* Best-effort POST. Drops on any error. */
static void otlp_post(const char* path, const char* body) {
if (!_otlp_endpoint || !body || !*body) return;
char url[1024];
snprintf(url, sizeof(url), "%s%s", _otlp_endpoint, path);
CURL* c = curl_easy_init();
if (!c) return;
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, 3000L);
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, NULL); /* discard response */
curl_easy_perform(c);
curl_slist_free_all(h);
curl_easy_cleanup(c);
}
/* Flush worker — runs forever until process exits */
static void* otlp_worker(void* arg) {
(void)arg;
while (1) {
struct timespec ts = { OTLP_FLUSH_MS / 1000, (OTLP_FLUSH_MS % 1000) * 1000000L };
nanosleep(&ts, NULL);
char* logs = otlp_drain(&_otlp_logs);
if (logs && *logs) {
char body[OTLP_BUF_CAP * 8];
int n = snprintf(body, sizeof(body),
"{\"resourceLogs\":[{\"resource\":%s,"
"\"scopeLogs\":[{\"scope\":{\"name\":\"el-runtime\"},"
"\"logRecords\":[%s]}]}]}",
otlp_resource_block(), logs);
if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/logs", body);
}
free(logs);
char* traces = otlp_drain(&_otlp_traces);
if (traces && *traces) {
char body[OTLP_BUF_CAP * 8];
int n = snprintf(body, sizeof(body),
"{\"resourceSpans\":[{\"resource\":%s,"
"\"scopeSpans\":[{\"scope\":{\"name\":\"el-runtime\"},"
"\"spans\":[%s]}]}]}",
otlp_resource_block(), traces);
if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/traces", body);
}
free(traces);
char* metrics = otlp_drain(&_otlp_metrics);
if (metrics && *metrics) {
char body[OTLP_BUF_CAP * 8];
int n = snprintf(body, sizeof(body),
"{\"resourceMetrics\":[{\"resource\":%s,"
"\"scopeMetrics\":[{\"scope\":{\"name\":\"el-runtime\"},"
"\"metrics\":[%s]}]}]}",
otlp_resource_block(), metrics);
if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/metrics", body);
}
free(metrics);
}
return NULL;
}
/* Initialize OTLP — called lazily on first emit. Idempotent. */
static void otlp_lazy_init(void) {
if (_otlp_initialized) return;
static pthread_mutex_t once_mu = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&once_mu);
if (_otlp_initialized) { pthread_mutex_unlock(&once_mu); return; }
const char* ep = getenv("OTLP_ENDPOINT");
if (!ep || !*ep) {
_otlp_initialized = 1;
pthread_mutex_unlock(&once_mu);
return;
}
_otlp_endpoint = strdup(ep);
/* trim trailing slash */
size_t l = strlen(_otlp_endpoint);
if (l > 0 && _otlp_endpoint[l - 1] == '/') _otlp_endpoint[l - 1] = '\0';
const char* svc = getenv("OTEL_SERVICE_NAME");
_otlp_service_name = strdup(svc && *svc ? svc : "el-app");
const char* ver = getenv("OTEL_SERVICE_VERSION");
_otlp_service_version = strdup(ver && *ver ? ver : "0.0.0");
pthread_create(&_otlp_worker_thread, NULL, otlp_worker, NULL);
pthread_detach(_otlp_worker_thread);
_otlp_initialized = 1;
pthread_mutex_unlock(&once_mu);
}
/* JSON-escape a string into out_buf. Returns chars written (excluding null). */
static size_t otlp_json_escape(const char* in, char* out, size_t out_cap) {
size_t o = 0;
for (size_t i = 0; in[i] && o + 8 < out_cap; i++) {
unsigned char c = (unsigned char)in[i];
if (c == '"') { out[o++] = '\\'; out[o++] = '"'; }
else if (c == '\\'){ out[o++] = '\\'; out[o++] = '\\'; }
else if (c == '\n'){ out[o++] = '\\'; out[o++] = 'n'; }
else if (c == '\r'){ out[o++] = '\\'; out[o++] = 'r'; }
else if (c == '\t'){ out[o++] = '\\'; out[o++] = 't'; }
else if (c < 0x20) { o += snprintf(out + o, out_cap - o, "\\u%04x", c); }
else { out[o++] = (char)c; }
}
out[o] = '\0';
return o;
}
/* ── Public El primitives ─────────────────────────────────────────────────── */
/* emit_log(level, msg, fields_json) — fields_json is a JSON object string or "" */
el_val_t emit_log(el_val_t level_v, el_val_t msg_v, el_val_t fields_v) {
otlp_lazy_init();
if (!_otlp_endpoint) return EL_INT(0);
const char* level = EL_CSTR(level_v); if (!level) level = "INFO";
const char* msg = EL_CSTR(msg_v); if (!msg) msg = "";
const char* fields = EL_CSTR(fields_v); if (!fields) fields = "";
/* Map El level names to OTLP severity numbers */
int sev_num = 9; /* INFO */
if (strcmp(level, "TRACE") == 0) sev_num = 1;
else if (strcmp(level, "DEBUG") == 0) sev_num = 5;
else if (strcmp(level, "INFO") == 0) sev_num = 9;
else if (strcmp(level, "WARN") == 0 || strcmp(level, "WARNING") == 0) sev_num = 13;
else if (strcmp(level, "ERROR") == 0) sev_num = 17;
else if (strcmp(level, "FATAL") == 0) sev_num = 21;
char esc_msg[2048]; otlp_json_escape(msg, esc_msg, sizeof(esc_msg));
/* unix nanos */
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
char rec[4096];
int n = snprintf(rec, sizeof(rec),
"{\"timeUnixNano\":\"%lld\",\"severityNumber\":%d,"
"\"severityText\":\"%s\","
"\"body\":{\"stringValue\":\"%s\"}%s%s}",
now_nano, sev_num, level, esc_msg,
(fields && *fields) ? ",\"attributes\":" : "",
(fields && *fields) ? fields : "");
if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_logs, rec);
return EL_INT(1);
}
/* emit_metric(name, value, tags_json) — Sum (counter) data point. tags_json
* is a JSON array of {key, value} pairs or empty string. */
el_val_t emit_metric(el_val_t name_v, el_val_t value_v, el_val_t tags_v) {
otlp_lazy_init();
if (!_otlp_endpoint) return EL_INT(0);
const char* name = EL_CSTR(name_v); if (!name) name = "unknown";
int64_t val = (int64_t)value_v;
const char* tags = EL_CSTR(tags_v); if (!tags) tags = "";
char esc_name[256]; otlp_json_escape(name, esc_name, sizeof(esc_name));
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
char rec[4096];
int n = snprintf(rec, sizeof(rec),
"{\"name\":\"%s\",\"sum\":{\"aggregationTemporality\":2,\"isMonotonic\":true,"
"\"dataPoints\":[{\"asInt\":\"%lld\","
"\"timeUnixNano\":\"%lld\""
"%s%s}]}}",
esc_name, (long long)val, now_nano,
(tags && *tags) ? ",\"attributes\":" : "",
(tags && *tags) ? tags : "");
if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_metrics, rec);
return EL_INT(1);
}
/* trace_span_start(name) — returns a span handle (string of "traceid:spanid:start_nano:name") */
el_val_t trace_span_start(el_val_t name_v) {
otlp_lazy_init();
const char* name = EL_CSTR(name_v); if (!name) name = "span";
/* generate 16-byte trace id and 8-byte span id */
static _Thread_local int seeded = 0;
if (!seeded) { srand((unsigned int)(uintptr_t)pthread_self() ^ (unsigned int)time(NULL)); seeded = 1; }
char tid[33], sid[17];
for (int i = 0; i < 32; i++) tid[i] = "0123456789abcdef"[rand() & 0xF];
tid[32] = '\0';
for (int i = 0; i < 16; i++) sid[i] = "0123456789abcdef"[rand() & 0xF];
sid[16] = '\0';
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
char* handle = malloc(strlen(name) + 80);
if (!handle) return EL_STR("");
sprintf(handle, "%s:%s:%lld:%s", tid, sid, now_nano, name);
el_arena_track(handle);
return EL_STR(handle);
}
/* trace_span_end(handle) — emits the span with computed duration */
el_val_t trace_span_end(el_val_t handle_v) {
otlp_lazy_init();
if (!_otlp_endpoint) return EL_INT(0);
const char* h = EL_CSTR(handle_v); if (!h) return EL_INT(0);
/* parse "tid:sid:start_nano:name" */
char tid[64], sid[32], rest[1024];
long long start_nano = 0;
if (sscanf(h, "%63[^:]:%31[^:]:%lld:%1023[^\n]", tid, sid, &start_nano, rest) != 4) return EL_INT(0);
struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
long long end_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
char esc_name[1024]; otlp_json_escape(rest, esc_name, sizeof(esc_name));
char rec[4096];
int n = snprintf(rec, sizeof(rec),
"{\"traceId\":\"%s\",\"spanId\":\"%s\","
"\"name\":\"%s\","
"\"kind\":1,"
"\"startTimeUnixNano\":\"%lld\","
"\"endTimeUnixNano\":\"%lld\","
"\"status\":{\"code\":1}}",
tid, sid, esc_name, start_nano, end_nano);
if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_traces, rec);
return EL_INT(1);
}
/* Convenience: emit a one-shot timed event (emit start+end immediately).
* For El programs that want point events with duration baked in. */
el_val_t emit_event(el_val_t name_v, el_val_t duration_ms_v) {
otlp_lazy_init();
if (!_otlp_endpoint) return EL_INT(0);
const char* name = EL_CSTR(name_v); if (!name) name = "event";
int64_t dur_ms = (int64_t)duration_ms_v;
el_val_t h = trace_span_start(EL_STR((char*)name));
/* fudge start to be (now - duration) */
(void)dur_ms;
return trace_span_end(h);
}
#endif /* HAVE_CURL — OTLP */
/* ── Threading seed primitives ───────────────────────────────────────────────
* __thread_create(fn_name, arg) -> Int spawn El fn in a pthread, return tid
* __thread_join(tid) -> String join thread, return result string
* __mutex_new() -> Int allocate a mutex, return handle
* __mutex_lock(m) lock mutex m
* __mutex_unlock(m) unlock mutex m
*
* Every El fn compiles to a global C symbol. __thread_create uses dlsym to
* look up the function by name and run it in a pthread. This means any El fn
* with signature (String) -> String is directly threadable.
*/
typedef el_val_t (*ElFn1)(el_val_t);
typedef struct {
ElFn1 fn;
el_val_t arg;
el_val_t result;
} ElThreadArg;
#define EL_THREAD_MAX 256
typedef struct {
pthread_t tid;
ElThreadArg* arg;
int alive;
} ElThread;
static ElThread _threads[EL_THREAD_MAX];
static int _thread_count = 0;
static pthread_mutex_t _thread_alloc_mu = PTHREAD_MUTEX_INITIALIZER;
static void* el_thread_runner(void* raw) {
ElThreadArg* a = (ElThreadArg*)raw;
a->result = a->fn(a->arg);
return NULL;
}
el_val_t __thread_create(el_val_t fn_name_v, el_val_t arg_v) {
const char* sym = EL_CSTR(fn_name_v);
if (!sym || !*sym) return EL_INT(-1);
void* p = dlsym(RTLD_DEFAULT, sym);
if (!p) {
fprintf(stderr, "[__thread_create] symbol not found: %s\n", sym);
return EL_INT(-1);
}
ElThreadArg* a = (ElThreadArg*)malloc(sizeof(ElThreadArg));
if (!a) return EL_INT(-1);
a->fn = (ElFn1)p;
a->arg = arg_v;
a->result = EL_STR("");
pthread_mutex_lock(&_thread_alloc_mu);
if (_thread_count >= EL_THREAD_MAX) {
pthread_mutex_unlock(&_thread_alloc_mu);
free(a);
fprintf(stderr, "[__thread_create] thread table full\n");
return EL_INT(-1);
}
int slot = _thread_count++;
_threads[slot].arg = a;
_threads[slot].alive = 1;
pthread_mutex_unlock(&_thread_alloc_mu);
if (pthread_create(&_threads[slot].tid, NULL, el_thread_runner, a) != 0) {
pthread_mutex_lock(&_thread_alloc_mu);
_thread_count--;
pthread_mutex_unlock(&_thread_alloc_mu);
free(a);
return EL_INT(-1);
}
return EL_INT(slot);
}
el_val_t __thread_join(el_val_t tid_v) {
int slot = (int)(int64_t)tid_v;
if (slot < 0 || slot >= EL_THREAD_MAX) return EL_STR("");
pthread_join(_threads[slot].tid, NULL);
el_val_t result = _threads[slot].arg->result;
free(_threads[slot].arg);
_threads[slot].alive = 0;
return result;
}
/* Mutex table */
#define EL_MUTEX_MAX 64
typedef struct {
pthread_mutex_t mu;
int allocated;
} ElMutexEntry;
static ElMutexEntry _mutexes[EL_MUTEX_MAX];
static int _mutex_count = 0;
static pthread_mutex_t _mutex_alloc_mu = PTHREAD_MUTEX_INITIALIZER;
el_val_t __mutex_new(void) {
pthread_mutex_lock(&_mutex_alloc_mu);
if (_mutex_count >= EL_MUTEX_MAX) {
pthread_mutex_unlock(&_mutex_alloc_mu);
fprintf(stderr, "[__mutex_new] mutex table full\n");
return EL_INT(-1);
}
int slot = _mutex_count++;
pthread_mutex_init(&_mutexes[slot].mu, NULL);
_mutexes[slot].allocated = 1;
pthread_mutex_unlock(&_mutex_alloc_mu);
return EL_INT(slot);
}
void __mutex_lock(el_val_t m_v) {
int slot = (int)(int64_t)m_v;
if (slot < 0 || slot >= EL_MUTEX_MAX || !_mutexes[slot].allocated) return;
pthread_mutex_lock(&_mutexes[slot].mu);
}
void __mutex_unlock(el_val_t m_v) {
int slot = (int)(int64_t)m_v;
if (slot < 0 || slot >= EL_MUTEX_MAX || !_mutexes[slot].allocated) return;
pthread_mutex_unlock(&_mutexes[slot].mu);
}
/* ── Channels ─────────────────────────────────────────────────────────────── *
* Buffered MPMC channel backed by a mutex + condvar + circular buffer.
* channel_new(capacity) -> Int (handle)
* channel_send(ch, msg) — blocks if full (capacity > 0) or never (unbounded)
* channel_recv(ch) -> String — blocks until a message is available
* channel_try_recv(ch) -> String — non-blocking, returns "" if empty
* channel_close(ch) — signal no more sends; recv drains remaining
*
* Bounded channels (cap > 0): circular buffer, sender blocks when full.
* Unbounded channels (cap == 0): dynamic array, sender never blocks.
*/
#define EL_CHANNEL_MAX 64
#define EL_CHANNEL_BUF 1024
typedef struct {
char** buf;
int cap; /* 0 = unbounded (grows dynamically) */
int head, tail, count;
int dyn_cap; /* allocated slots for unbounded mode */
int closed;
pthread_mutex_t mu;
pthread_cond_t not_empty;
pthread_cond_t not_full;
} ElChannel;
static ElChannel _channels[EL_CHANNEL_MAX];
static int _channel_count = 0;
static pthread_mutex_t _channel_alloc_mu = PTHREAD_MUTEX_INITIALIZER;
el_val_t __channel_new(el_val_t capacity_v) {
int cap = (int)(int64_t)capacity_v;
if (cap < 0) cap = 0;
pthread_mutex_lock(&_channel_alloc_mu);
if (_channel_count >= EL_CHANNEL_MAX) {
pthread_mutex_unlock(&_channel_alloc_mu);
fprintf(stderr, "[__channel_new] channel table full\n");
return EL_INT(-1);
}
int slot = _channel_count++;
pthread_mutex_unlock(&_channel_alloc_mu);
ElChannel* ch = &_channels[slot];
memset(ch, 0, sizeof(*ch));
ch->cap = cap;
ch->closed = 0;
ch->head = 0;
ch->tail = 0;
ch->count = 0;
if (cap > 0) {
/* Bounded: fixed circular buffer. */
ch->buf = (char**)malloc((size_t)cap * sizeof(char*));
ch->dyn_cap = cap;
} else {
/* Unbounded: start with EL_CHANNEL_BUF slots, grow as needed. */
ch->buf = (char**)malloc(EL_CHANNEL_BUF * sizeof(char*));
ch->dyn_cap = EL_CHANNEL_BUF;
}
if (!ch->buf) {
fprintf(stderr, "[__channel_new] out of memory\n");
return EL_INT(-1);
}
pthread_mutex_init(&ch->mu, NULL);
pthread_cond_init(&ch->not_empty, NULL);
pthread_cond_init(&ch->not_full, NULL);
return EL_INT(slot);
}
void __channel_send(el_val_t ch_v, el_val_t msg_v) {
int slot = (int)(int64_t)ch_v;
if (slot < 0 || slot >= EL_CHANNEL_MAX) return;
ElChannel* ch = &_channels[slot];
const char* msg = EL_CSTR(msg_v);
if (!msg) msg = "";
char* copy = strdup(msg); /* channel owns the string */
pthread_mutex_lock(&ch->mu);
if (ch->closed) {
/* Send on closed channel is a no-op (drop the message). */
pthread_mutex_unlock(&ch->mu);
free(copy);
return;
}
if (ch->cap > 0) {
/* Bounded: block while full. */
while (ch->count >= ch->cap && !ch->closed) {
pthread_cond_wait(&ch->not_full, &ch->mu);
}
if (ch->closed) {
pthread_mutex_unlock(&ch->mu);
free(copy);
return;
}
ch->buf[ch->tail] = copy;
ch->tail = (ch->tail + 1) % ch->cap;
ch->count++;
} else {
/* Unbounded: grow the buffer if needed. */
if (ch->count >= ch->dyn_cap) {
int new_cap = ch->dyn_cap * 2;
char** grown = (char**)realloc(ch->buf, (size_t)new_cap * sizeof(char*));
if (!grown) {
pthread_mutex_unlock(&ch->mu);
free(copy);
fprintf(stderr, "[__channel_send] out of memory growing channel\n");
return;
}
/* The circular buffer may have wrapped. Linearise it first.
* In unbounded mode head is always 0 (we append at tail, drain
* from head), so a simple memmove isn't needed — but if the
* buffer did wrap (tail < head after growth), we need to fix up.
* Simplest safe path: if tail wrapped, move the head..old_cap
* segment to new_cap..new_cap+(old_cap-head). */
if (ch->tail < ch->head) {
/* Wrapped: [head..old_cap) is the front, [0..tail) is the back. */
int front = ch->dyn_cap - ch->head;
memmove(grown + ch->dyn_cap, grown + ch->head, (size_t)front * sizeof(char*));
ch->head = ch->dyn_cap;
}
ch->buf = grown;
ch->dyn_cap = new_cap;
}
ch->buf[ch->tail] = copy;
ch->tail = (ch->tail + 1) % ch->dyn_cap;
ch->count++;
}
pthread_cond_signal(&ch->not_empty);
pthread_mutex_unlock(&ch->mu);
}
el_val_t __channel_recv(el_val_t ch_v) {
int slot = (int)(int64_t)ch_v;
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
ElChannel* ch = &_channels[slot];
pthread_mutex_lock(&ch->mu);
/* Block until there is a message or the channel is closed and drained. */
while (ch->count == 0 && !ch->closed) {
pthread_cond_wait(&ch->not_empty, &ch->mu);
}
if (ch->count == 0) {
/* Closed and empty — signal EOF. */
pthread_mutex_unlock(&ch->mu);
return EL_STR("");
}
int buf_cap = (ch->cap > 0) ? ch->cap : ch->dyn_cap;
char* msg = ch->buf[ch->head];
ch->head = (ch->head + 1) % buf_cap;
ch->count--;
pthread_cond_signal(&ch->not_full);
pthread_mutex_unlock(&ch->mu);
/* Hand the string to the arena so it is freed after the request. */
el_arena_track(msg);
return EL_STR(msg);
}
el_val_t __channel_try_recv(el_val_t ch_v) {
int slot = (int)(int64_t)ch_v;
if (slot < 0 || slot >= EL_CHANNEL_MAX) return EL_STR("");
ElChannel* ch = &_channels[slot];
pthread_mutex_lock(&ch->mu);
if (ch->count == 0) {
pthread_mutex_unlock(&ch->mu);
return EL_STR("");
}
int buf_cap = (ch->cap > 0) ? ch->cap : ch->dyn_cap;
char* msg = ch->buf[ch->head];
ch->head = (ch->head + 1) % buf_cap;
ch->count--;
pthread_cond_signal(&ch->not_full);
pthread_mutex_unlock(&ch->mu);
el_arena_track(msg);
return EL_STR(msg);
}
void __channel_close(el_val_t ch_v) {
int slot = (int)(int64_t)ch_v;
if (slot < 0 || slot >= EL_CHANNEL_MAX) return;
ElChannel* ch = &_channels[slot];
pthread_mutex_lock(&ch->mu);
ch->closed = 1;
/* Wake all blocked recvers and senders so they can observe the close. */
pthread_cond_broadcast(&ch->not_empty);
pthread_cond_broadcast(&ch->not_full);
pthread_mutex_unlock(&ch->mu);
}
/* ── DHARMA runtime additions ────────────────────────────────────────────────
*
* Functions required by the dharma registry service. Added here so the
* released el_runtime.c includes them without requiring dharma to bundle
* its own stubs.
*
* Functions added:
* list_len — alias for el_list_len (used in handlers.el)
* list_get — alias for el_list_get (used in handlers.el)
* json_array_push — append a pre-encoded JSON element to a JSON array string
* now_millis — milliseconds since Unix epoch (alias for time_now)
* unix_timestamp_ms — same as now_millis (alias)
* time_now_ms — same as now_millis (alias)
* log_info — stderr structured log at INFO level
* log_warn — stderr structured log at WARN level
* config — reads a config value from the environment
* http_patch — HTTP PATCH with JSON Content-Type
* http_post_engram — HTTP POST with optional X-API-Key header
* http_get_engram — HTTP GET with optional X-API-Key header
* str_to_bytes — encode a string as a JSON array of byte values
* bytes_to_str — decode a JSON array of byte values back to a string
* hash_sha256 — SHA-256 hex digest of a string
*/
/* list_len — return the number of elements in a list. */
el_val_t list_len(el_val_t list) {
return el_list_len(list);
}
/* list_get — return the element at index i in a list. */
el_val_t list_get(el_val_t list, el_val_t index) {
return el_list_get(list, index);
}
/* json_array_push — append element (a pre-encoded JSON fragment, e.g. "\"foo\""
* or "42") to the JSON array string arr. Returns a new JSON array string.
* Example: json_array_push("[]", "\"alice\"") -> "[\"alice\"]"
* json_array_push("[\"alice\"]", "\"bob\"") -> "[\"alice\",\"bob\"]" */
el_val_t json_array_push(el_val_t arr_v, el_val_t elem_v) {
const char* arr = EL_CSTR(arr_v);
const char* elem = EL_CSTR(elem_v);
if (!arr || !*arr) arr = "[]";
if (!elem || !*elem) elem = "null";
/* Trim whitespace, find the closing ']'. */
const char* p = arr;
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
if (*p != '[') {
/* Not an array — return a single-element array. */
size_t n = strlen(elem) + 4;
char* out = el_strbuf(n);
snprintf(out, n, "[%s]", elem);
return el_wrap_str(out);
}
size_t arr_len = strlen(arr);
size_t elem_len = strlen(elem);
/* Walk from the end to find the matching ']'. */
const char* end = arr + arr_len - 1;
while (end > p && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) end--;
if (*end != ']') {
/* Malformed — wrap elem in a new array. */
size_t n = elem_len + 4;
char* out = el_strbuf(n);
snprintf(out, n, "[%s]", elem);
return el_wrap_str(out);
}
/* Content between '[' and ']'. */
const char* inner_start = p + 1;
const char* inner_end = end; /* points AT ']' */
/* Check if the array is empty (only whitespace between brackets). */
const char* q = inner_start;
while (q < inner_end && (*q == ' ' || *q == '\t' || *q == '\n' || *q == '\r')) q++;
int empty = (q == inner_end);
/* Build: prefix + (comma if non-empty) + elem + "]" */
size_t prefix_len = (size_t)(inner_end - arr); /* up to but not including ']' */
size_t sep_len = empty ? 0 : 1; /* "," if non-empty */
size_t out_len = prefix_len + sep_len + elem_len + 2; /* +"]" + NUL */
char* out = el_strbuf(out_len);
memcpy(out, arr, prefix_len);
if (!empty) out[prefix_len] = ',';
memcpy(out + prefix_len + sep_len, elem, elem_len);
out[prefix_len + sep_len + elem_len] = ']';
out[prefix_len + sep_len + elem_len + 1] = '\0';
return el_wrap_str(out);
}
/* now_millis — milliseconds since Unix epoch. */
el_val_t now_millis(void) {
return time_now();
}
/* unix_timestamp_ms — same as now_millis. */
el_val_t unix_timestamp_ms(void) {
return time_now();
}
/* time_now_ms — same as now_millis. */
el_val_t time_now_ms(void) {
return time_now();
}
/* log_info — write a structured [INFO] line to stderr. */
void log_info(el_val_t msg_v) {
const char* msg = EL_CSTR(msg_v);
fprintf(stderr, "[INFO] %s\n", msg ? msg : "");
}
/* log_warn — write a structured [WARN] line to stderr. */
void log_warn(el_val_t msg_v) {
const char* msg = EL_CSTR(msg_v);
fprintf(stderr, "[WARN] %s\n", msg ? msg : "");
}
/* config — read a configuration value from the environment.
* Returns "" if the variable is not set (same as __env_get). */
el_val_t config(el_val_t key_v) {
const char* key = EL_CSTR(key_v);
if (!key || !*key) return EL_STR("");
const char* val = getenv(key);
if (!val) return EL_STR("");
return el_wrap_str(el_strdup(val));
}
#ifdef HAVE_CURL
/* http_patch — HTTP PATCH request with Content-Type: application/json.
* Returns the response body (same error convention as http_post_json). */
el_val_t http_patch(el_val_t url_v, el_val_t body_v) {
const char* url = EL_CSTR(url_v);
const char* body = EL_CSTR(body_v);
if (!url || !*url) return http_error_json("empty url");
CURL* c = curl_easy_init();
if (!c) return http_error_json("curl_easy_init failed");
HttpBuf rb; httpbuf_init(&rb);
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH");
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
CURLcode rc = curl_easy_perform(c);
curl_slist_free_all(h);
curl_easy_cleanup(c);
if (rc != CURLE_OK) {
free(rb.data);
const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
return http_error_json(m);
}
return el_wrap_str(rb.data);
}
/* http_post_engram — HTTP POST with optional X-API-Key header.
* If key is "" no authentication header is sent. */
el_val_t http_post_engram(el_val_t url_v, el_val_t key_v, el_val_t body_v) {
const char* url = EL_CSTR(url_v);
const char* key = EL_CSTR(key_v);
const char* body = EL_CSTR(body_v);
if (!url || !*url) return http_error_json("empty url");
CURL* c = curl_easy_init();
if (!c) return http_error_json("curl_easy_init failed");
HttpBuf rb; httpbuf_init(&rb);
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
struct curl_slist* h = NULL;
h = curl_slist_append(h, "Content-Type: application/json");
if (key && *key) {
size_t n = strlen(key) + 32;
char* hdr = malloc(n);
snprintf(hdr, n, "X-API-Key: %s", key);
h = curl_slist_append(h, hdr);
free(hdr);
}
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
CURLcode rc = curl_easy_perform(c);
curl_slist_free_all(h);
curl_easy_cleanup(c);
if (rc != CURLE_OK) {
free(rb.data);
const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
return http_error_json(m);
}
return el_wrap_str(rb.data);
}
/* http_get_engram — HTTP GET with optional X-API-Key header. */
el_val_t http_get_engram(el_val_t url_v, el_val_t key_v) {
const char* url = EL_CSTR(url_v);
const char* key = EL_CSTR(key_v);
if (!url || !*url) return http_error_json("empty url");
CURL* c = curl_easy_init();
if (!c) return http_error_json("curl_easy_init failed");
HttpBuf rb; httpbuf_init(&rb);
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
struct curl_slist* h = NULL;
if (key && *key) {
size_t n = strlen(key) + 32;
char* hdr = malloc(n);
snprintf(hdr, n, "X-API-Key: %s", key);
h = curl_slist_append(h, hdr);
free(hdr);
}
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_HTTPGET, 1L);
if (h) curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
CURLcode rc = curl_easy_perform(c);
if (h) curl_slist_free_all(h);
curl_easy_cleanup(c);
if (rc != CURLE_OK) {
free(rb.data);
const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
return http_error_json(m);
}
return el_wrap_str(rb.data);
}
#endif /* HAVE_CURL */
/* str_to_bytes — encode a string as a JSON array of unsigned byte values.
* "hello" -> "[104,101,108,108,111]"
* Used by db.el to store binary content in Engram JSON nodes. */
el_val_t str_to_bytes(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s || !*s) return el_wrap_str(el_strdup("[]"));
size_t n = strlen(s);
/* Worst case: each byte is 3 digits + comma = 4 chars, plus "[]" + NUL. */
char* out = el_strbuf(n * 4 + 3);
size_t pos = 0;
out[pos++] = '[';
for (size_t i = 0; i < n; i++) {
unsigned char b = (unsigned char)s[i];
if (i > 0) out[pos++] = ',';
/* Write decimal representation of b. */
if (b >= 100) {
out[pos++] = (char)('0' + b / 100);
out[pos++] = (char)('0' + (b / 10) % 10);
out[pos++] = (char)('0' + b % 10);
} else if (b >= 10) {
out[pos++] = (char)('0' + b / 10);
out[pos++] = (char)('0' + b % 10);
} else {
out[pos++] = (char)('0' + b);
}
}
out[pos++] = ']';
out[pos] = '\0';
return el_wrap_str(out);
}
/* bytes_to_str — decode a JSON array of integer byte values back to a string.
* "[104,101,108,108,111]" -> "hello"
* Inverse of str_to_bytes. */
el_val_t bytes_to_str(el_val_t arr_v) {
const char* s = EL_CSTR(arr_v);
if (!s) return el_wrap_str(el_strdup(""));
/* Skip whitespace, expect '['. */
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s != '[') return el_wrap_str(el_strdup(""));
s++;
/* Count elements to size the output buffer. */
int64_t n = (int64_t)json_array_len(arr_v);
if (n <= 0) return el_wrap_str(el_strdup(""));
char* out = el_strbuf((size_t)n + 1);
size_t pos = 0;
/* Walk the array, parse each integer, store as a byte. */
while (*s) {
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ']' || *s == '\0') break;
/* Parse decimal integer. */
char* end_ptr;
long v = strtol(s, &end_ptr, 10);
if (end_ptr == s) break; /* parse failure */
s = end_ptr;
if (v >= 0 && v <= 255) out[pos++] = (char)(unsigned char)v;
while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
if (*s == ',') { s++; continue; }
if (*s == ']' || *s == '\0') break;
}
out[pos] = '\0';
return el_wrap_str(out);
}
/* hash_sha256 — return the SHA-256 hex digest of a string.
* Uses the built-in el_sha256_oneshot implementation (no OpenSSL required). */
el_val_t hash_sha256(el_val_t sv) {
const char* s = EL_CSTR(sv);
if (!s) s = "";
unsigned char digest[32];
el_sha256_oneshot((const unsigned char*)s, strlen(s), digest);
return el_hex_encode(digest, 32);
}
/* ── __ prefixed aliases — public boundary for compiled El programs ──────────
*
* The El compiler's self-hosting back-end emits calls to __-prefixed function
* names (e.g. __println, __str_len). These wrappers forward to the existing
* el_runtime implementations so both naming conventions resolve at link time.
*
* Note: __thread_create and __thread_join are already defined above in the
* threading section; they are not repeated here.
* ──────────────────────────────────────────────────────────────────────────── */
/* I/O */
el_val_t __println(el_val_t s) { return println(s); }
el_val_t __print(el_val_t s) { return print(s); }
el_val_t __readline(void) { return readline(); }
/* String */
el_val_t __int_to_str(el_val_t n) { return int_to_str(n); }
el_val_t __str_to_int(el_val_t s) { return str_to_int(s); }
el_val_t __float_to_str(el_val_t f) { return float_to_str(f); }
el_val_t __str_to_float(el_val_t s) { return str_to_float(s); }
el_val_t __str_len(el_val_t s) { return str_len(s); }
el_val_t __str_char_at(el_val_t s, el_val_t i) { return str_char_at(s, i); }
el_val_t __str_cmp(el_val_t a, el_val_t b) {
const char* ca = EL_CSTR(a);
const char* cb = EL_CSTR(b);
if (!ca) ca = "";
if (!cb) cb = "";
return (el_val_t)strcmp(ca, cb);
}
el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n) {
const char* ca = EL_CSTR(a);
const char* cb = EL_CSTR(b);
if (!ca) ca = "";
if (!cb) cb = "";
return (el_val_t)strncmp(ca, cb, (size_t)n);
}
el_val_t __str_concat_raw(el_val_t a, el_val_t b) { return str_concat(a, b); }
el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end) { return str_slice(s, start, end); }
el_val_t __str_alloc(el_val_t n) {
if (n <= 0) n = 0;
char* buf = el_strbuf((size_t)n + 1);
memset(buf, 0, (size_t)n + 1);
return el_wrap_str(buf);
}
el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
char* buf = (char*)(uintptr_t)s;
if (buf) buf[(size_t)i] = (char)c;
return s;
}
/* URL encoding */
el_val_t __url_encode(el_val_t s) { return url_encode(s); }
el_val_t __url_decode(el_val_t s) { return url_decode(s); }
/* Environment */
el_val_t __env_get(el_val_t key) { return env(key); }
/* Subprocess */
el_val_t __exec(el_val_t cmd) { return exec(cmd); }
el_val_t __exec_bg(el_val_t cmd) { return exec_bg(cmd); }
/* Process */
el_val_t __exit_program(el_val_t code) { return exit_program(code); }
/* Filesystem */
el_val_t __fs_exists(el_val_t path) { return fs_exists(path); }
el_val_t __fs_mkdir(el_val_t path) { return fs_mkdir(path); }
el_val_t __fs_read(el_val_t path) { return fs_read(path); }
el_val_t __fs_write(el_val_t path, el_val_t content) { return fs_write(path, content); }
el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n) { return fs_write_bytes(path, bytes, n); }
el_val_t __fs_list_raw(el_val_t path) { return fs_list_json(path); }
/* HTTP server (no curl dependency) */
el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body) { return http_response(status, headers_json, body); }
el_val_t __http_serve(el_val_t port, el_val_t handler) { return http_serve(port, handler); }
el_val_t __http_serve_v2(el_val_t port, el_val_t handler) { return http_serve_v2(port, handler); }
/* HTTP conn fd / SSE — __http_conn_fd lives in el_seed.c; stubs provided here
* so el_runtime.c compiles standalone. When both translation units are linked
* the el_seed.c definitions win via their non-static linkage (strong symbols).
* These stubs are marked weak so they are silently overridden. */
__attribute__((weak)) el_val_t __http_conn_fd(void) { return (el_val_t)(-1); }
__attribute__((weak)) el_val_t __http_sse_open(el_val_t conn_id) { (void)conn_id; return 0; }
__attribute__((weak)) el_val_t __http_sse_send(el_val_t conn_id, el_val_t data) { (void)conn_id; (void)data; return 0; }
__attribute__((weak)) el_val_t __http_sse_close(el_val_t conn_id) { (void)conn_id; return 0; }
/* JSON */
el_val_t __json_array_get(el_val_t json, el_val_t index) { return json_array_get(json, index); }
el_val_t __json_array_get_string(el_val_t json, el_val_t index) { return json_array_get_string(json, index); }
el_val_t __json_array_len(el_val_t json) { return json_array_len(json); }
el_val_t __json_get(el_val_t json, el_val_t key) { return json_get(json, key); }
el_val_t __json_get_raw(el_val_t json, el_val_t key) { return json_get_raw(json, key); }
el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value){ return json_set(json, key, value); }
el_val_t __json_parse_map(el_val_t json_str) { return json_parse(json_str); }
el_val_t __json_stringify_val(el_val_t val) { return json_stringify(val); }
/* Hashing */
el_val_t __sha256_hex(el_val_t s) { return hash_sha256(s); }
/* State K/V */
el_val_t __state_del(el_val_t key) { return state_del(key); }
el_val_t __state_get(el_val_t key) { return state_get(key); }
el_val_t __state_keys(void) { return state_keys(); }
el_val_t __state_set(el_val_t key, el_val_t val) { return state_set(key, val); }
/* UUID */
el_val_t __uuid_v4(void) { return uuid_v4(); }
/* Args */
el_val_t __args_json(void) { return args(); }
/* HTTP client aliases — require curl; defined inside #ifdef HAVE_CURL below
* with a matching stub in the #ifndef HAVE_CURL block. */
#ifdef HAVE_CURL
el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
el_val_t headers_map, el_val_t timeout_ms) {
/* timeout_ms is accepted for API compatibility but ignored here;
* el_runtime's http_do uses the EL_HTTP_TIMEOUT_MS env var instead. */
(void)timeout_ms;
struct curl_slist* h = headers_from_map(headers_map);
el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
if (h) curl_slist_free_all(h);
return r;
}
/* __http_do_map — same as __http_do but headers_map arg is a JSON-string
* rather than an ElMap. Parse it first, then delegate. */
el_val_t __http_do_map(el_val_t method, el_val_t url, el_val_t body,
el_val_t headers_json, el_val_t timeout_ms) {
(void)timeout_ms;
/* Build a curl_slist from a JSON object {"Header":"value",...}. */
const char* hj = EL_CSTR(headers_json);
struct curl_slist* h = NULL;
if (hj && *hj && *hj == '{') {
/* Walk the JSON pairs with a simple parser reusing json_get_string logic. */
/* For correctness we just call the existing json_get iteration path.
* We duplicate the key-extraction loop from headers_from_map but driven
* by JSON rather than ElMap. Use json_get_raw to iterate is not easy
* without knowing keys, so accept the JSON string and build a tmp map. */
el_val_t map = json_parse(EL_STR(hj));
h = headers_from_map(map);
}
el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
if (h) curl_slist_free_all(h);
return r;
}
/* __http_do_map_to_file — same as __http_do_map but streams response body
* to a local file path rather than returning it as a string. */
el_val_t __http_do_map_to_file(el_val_t method, el_val_t url, el_val_t body,
el_val_t headers_json, el_val_t output_path) {
const char* hj = EL_CSTR(headers_json);
struct curl_slist* h = NULL;
if (hj && *hj && *hj == '{') {
el_val_t map = json_parse(EL_STR(hj));
h = headers_from_map(map);
}
el_val_t r = http_do_to_file(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body),
h, EL_CSTR(output_path));
if (h) curl_slist_free_all(h);
return r;
}
#endif /* HAVE_CURL */
#ifndef HAVE_CURL
/* ── HAVE_CURL=0 stubs — compile without -lcurl for the elc CLI binary. ───── *
* These return a JSON error string so El programs get a clear message if they
* call HTTP/LLM functions in a curl-less build. */
static el_val_t _no_curl_err(void) {
return el_wrap_str(el_strdup("{\"error\":\"not built with HAVE_CURL\"}"));
}
el_val_t http_get(el_val_t url) { (void)url; return _no_curl_err(); }
el_val_t http_post(el_val_t url, el_val_t body) { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_post_json(el_val_t url, el_val_t body) { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_get_with_headers(el_val_t url, el_val_t h) { (void)url; (void)h; return _no_curl_err(); }
el_val_t http_post_with_headers(el_val_t url, el_val_t b, el_val_t h) { (void)url; (void)b; (void)h; return _no_curl_err(); }
el_val_t http_post_json_with_headers(el_val_t url, el_val_t h, el_val_t b) { (void)url; (void)h; (void)b; return _no_curl_err(); }
el_val_t http_post_form_auth(el_val_t url, el_val_t b, el_val_t a) { (void)url; (void)b; (void)a; return _no_curl_err(); }
el_val_t http_delete(el_val_t url) { (void)url; return _no_curl_err(); }
el_val_t http_patch(el_val_t url, el_val_t body) { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_get_to_file(el_val_t url, el_val_t h, el_val_t p) { (void)url; (void)h; (void)p; return _no_curl_err(); }
el_val_t http_post_to_file(el_val_t url, el_val_t b, el_val_t h, el_val_t p) { (void)url; (void)b; (void)h; (void)p; return _no_curl_err(); }
el_val_t http_post_engram(el_val_t url, el_val_t k, el_val_t b) { (void)url; (void)k; (void)b; return _no_curl_err(); }
el_val_t http_get_engram(el_val_t url, el_val_t k) { (void)url; (void)k; return _no_curl_err(); }
el_val_t llm_call(el_val_t m, el_val_t p) { (void)m; (void)p; return _no_curl_err(); }
el_val_t llm_call_system(el_val_t m, el_val_t s, el_val_t u) { (void)m; (void)s; (void)u; return _no_curl_err(); }
el_val_t llm_call_agentic(el_val_t m, el_val_t s, el_val_t u, el_val_t t) { (void)m; (void)s; (void)u; (void)t; return _no_curl_err(); }
el_val_t llm_vision(el_val_t m, el_val_t s, el_val_t p, el_val_t i) { (void)m; (void)s; (void)p; (void)i; return _no_curl_err(); }
el_val_t llm_models(void) { return el_list_empty(); }
void llm_register_tool(el_val_t n, el_val_t f) { (void)n; (void)f; }
/* __ HTTP stubs (no-curl build) */
el_val_t __http_do(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
el_val_t __http_do_map(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
el_val_t __http_do_map_to_file(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t p) { (void)m; (void)u; (void)b; (void)h; (void)p; return _no_curl_err(); }
#endif /* !HAVE_CURL */