diff --git a/ui/examples/native-hello-android/.gitignore b/ui/examples/native-hello-android/.gitignore
new file mode 100644
index 0000000..0ca7c87
--- /dev/null
+++ b/ui/examples/native-hello-android/.gitignore
@@ -0,0 +1,6 @@
+local.properties
+.gradle/
+build/
+app/build/
+*.iml
+.idea/
diff --git a/ui/examples/native-hello-android/app/build.gradle b/ui/examples/native-hello-android/app/build.gradle
new file mode 100644
index 0000000..dd1dd28
--- /dev/null
+++ b/ui/examples/native-hello-android/app/build.gradle
@@ -0,0 +1,55 @@
+plugins {
+ id 'com.android.application'
+}
+
+android {
+ namespace 'com.neuron.el'
+ compileSdk 34
+
+ defaultConfig {
+ applicationId "com.neuron.el"
+ minSdk 21
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ externalNativeBuild {
+ cmake {
+ cppFlags ""
+ arguments "-DANDROID_STL=c++_shared"
+ }
+ }
+
+ ndk {
+ // Build for the two most relevant ABIs. Add x86/x86_64 for emulator.
+ abiFilters "arm64-v8a", "armeabi-v7a", "x86_64"
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path "src/main/jni/CMakeLists.txt"
+ version "3.22.1"
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ debug {
+ jniDebuggable true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+}
+
+dependencies {
+ // No third-party dependencies — el-native uses only android.* framework classes.
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+}
diff --git a/ui/examples/native-hello-android/app/proguard-rules.pro b/ui/examples/native-hello-android/app/proguard-rules.pro
new file mode 100644
index 0000000..c0c619f
--- /dev/null
+++ b/ui/examples/native-hello-android/app/proguard-rules.pro
@@ -0,0 +1,4 @@
+# Add project specific ProGuard rules here.
+# Keep ElBridge and MainActivity so JNI symbol names stay intact.
+-keep class com.neuron.el.ElBridge { *; }
+-keep class com.neuron.el.MainActivity { *; }
diff --git a/ui/examples/native-hello-android/app/src/main/AndroidManifest.xml b/ui/examples/native-hello-android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..780d219
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElApp.java b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElApp.java
new file mode 100644
index 0000000..25f72d6
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElApp.java
@@ -0,0 +1,18 @@
+package com.neuron.el;
+
+import android.app.Application;
+
+/**
+ * ElApp — Application subclass for native-hello-android.
+ *
+ * Currently minimal: exists as an anchor for future app-level initialisation
+ * (crash reporting, global state, etc.). Listed in AndroidManifest.xml as
+ * android:name=".ElApp".
+ */
+public class ElApp extends Application {
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ }
+}
diff --git a/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElBridge.java b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElBridge.java
new file mode 100644
index 0000000..52cb402
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/ElBridge.java
@@ -0,0 +1,711 @@
+/*
+ * ElBridge.java — Android Java companion to el_android.c.
+ *
+ * All public methods are static. The C JNI layer calls these to create views,
+ * set properties, and manage the widget tree. Views are identified by integer
+ * slot indices matching the C-side handle values.
+ *
+ * Threading: every method that touches a View dispatches to the UI thread
+ * using Activity.runOnUiThread(Runnable) and blocks with a CountDownLatch
+ * until the UI thread completes the operation. This mirrors the AppKit
+ * dispatch_sync(main_queue, ^{}) pattern in el_appkit.m.
+ *
+ * Callbacks: Java sets listeners on views that call back into C via:
+ * nativeOnClick(int slot)
+ * nativeOnChange(int slot, String text)
+ * nativeOnSubmit(int slot, String text)
+ * These are declared native and implemented in el_android.c.
+ *
+ * Usage (in your Activity.onCreate):
+ * System.loadLibrary("elruntime");
+ * ElBridge.init(this);
+ *
+ * The native library calls __native_init() which calls nativeRegisterActivity
+ * via the C side; alternatively call ElBridge.init(this) directly from Java.
+ *
+ * Compile requirements:
+ * Android minSdkVersion 21 (Lollipop) or higher.
+ * No third-party dependencies — uses only android.* framework classes.
+ * For image loading from arbitrary file paths, BitmapFactory is used.
+ * To replace with Glide/Picasso, edit createImageView only.
+ */
+
+package com.neuron.el;
+
+import android.app.Activity;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.GradientDrawable;
+import android.os.Handler;
+import android.os.Looper;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import java.util.concurrent.CountDownLatch;
+
+public class ElBridge {
+
+ /* ── Native callbacks (implemented in el_android.c) ─────────────────── */
+
+ public static native void nativeOnClick(int slot);
+ public static native void nativeOnChange(int slot, String text);
+ public static native void nativeOnSubmit(int slot, String text);
+ public static native void nativeRegisterActivity(Activity activity);
+
+ /* ── State ───────────────────────────────────────────────────────────── */
+
+ private static final int MAX_SLOTS = 4096;
+
+ private static Activity sActivity;
+ private static Handler sUiHandler;
+ private static View[] sViews = new View[MAX_SLOTS];
+ private static int sNextSlot = 1; /* slot 0 reserved / null */
+
+ /* ── Init ────────────────────────────────────────────────────────────── */
+
+ /**
+ * Must be called from the Activity before any widget operations.
+ * Typically called from Activity.onCreate after System.loadLibrary.
+ */
+ public static void init(Activity activity) {
+ sActivity = activity;
+ sUiHandler = new Handler(Looper.getMainLooper());
+ nativeRegisterActivity(activity);
+ }
+
+ /* ── Slot management ─────────────────────────────────────────────────── */
+
+ private static int allocSlot(View v) {
+ /* Find a free slot starting from sNextSlot, wrap around. */
+ for (int i = 0; i < MAX_SLOTS - 1; i++) {
+ int idx = ((sNextSlot - 1 + i) % (MAX_SLOTS - 1)) + 1;
+ if (sViews[idx] == null) {
+ sViews[idx] = v;
+ sNextSlot = (idx % (MAX_SLOTS - 1)) + 1;
+ return idx;
+ }
+ }
+ android.util.Log.e("ElBridge", "allocSlot: slot table full");
+ return -1;
+ }
+
+ private static View getView(int slot) {
+ if (slot <= 0 || slot >= MAX_SLOTS) return null;
+ return sViews[slot];
+ }
+
+ /* ── UI-thread dispatch helper ───────────────────────────────────────── */
+
+ /*
+ * Dispatch r on the UI thread and block until it completes.
+ * Safe to call from the UI thread itself (runs inline without posting).
+ */
+ private static void runSync(final Runnable r) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ r.run();
+ } else {
+ final CountDownLatch latch = new CountDownLatch(1);
+ sUiHandler.post(new Runnable() {
+ @Override public void run() {
+ try { r.run(); } finally { latch.countDown(); }
+ }
+ });
+ try { latch.await(); } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /* ── Integer slot returning runSync helper ───────────────────────────── */
+
+ private interface IntSupplier { int get(); }
+
+ private static int runSyncInt(final IntSupplier s) {
+ final int[] result = { -1 };
+ runSync(new Runnable() {
+ @Override public void run() { result[0] = s.get(); }
+ });
+ return result[0];
+ }
+
+ /* ── Context accessor ────────────────────────────────────────────────── */
+
+ private static Context ctx() { return sActivity; }
+
+ /* ── View creation ───────────────────────────────────────────────────── */
+
+ /**
+ * Create a LinearLayout.
+ * @param orientation 1=VERTICAL, 0=HORIZONTAL
+ * @param spacing gap between children in dp; applied as bottom/right margin
+ */
+ public static int createLinearLayout(final int orientation, final int spacing) {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ LinearLayout ll = new LinearLayout(ctx());
+ ll.setOrientation(orientation == 1
+ ? LinearLayout.VERTICAL
+ : LinearLayout.HORIZONTAL);
+ ll.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ /* Spacing is stored so addChild can apply margins. */
+ ll.setTag(R_TAG_SPACING, spacing);
+ return allocSlot(ll);
+ }
+ });
+ }
+
+ /** Create a FrameLayout (ZStack equivalent). */
+ public static int createFrameLayout() {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ FrameLayout fl = new FrameLayout(ctx());
+ fl.setLayoutParams(new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ return allocSlot(fl);
+ }
+ });
+ }
+
+ /** Create a ScrollView. */
+ public static int createScrollView() {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ ScrollView sv = new ScrollView(ctx());
+ sv.setLayoutParams(new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+ sv.setFillViewport(true);
+ return allocSlot(sv);
+ }
+ });
+ }
+
+ /** Create a TextView with initial text. */
+ public static int createTextView(final String text) {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ TextView tv = new TextView(ctx());
+ tv.setText(text != null ? text : "");
+ tv.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ return allocSlot(tv);
+ }
+ });
+ }
+
+ /** Create a Button with a label. */
+ public static int createButton(final String label) {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ Button btn = new Button(ctx());
+ btn.setText(label != null ? label : "");
+ btn.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ return allocSlot(btn);
+ }
+ });
+ }
+
+ /**
+ * Create an EditText.
+ * @param placeholder hint text
+ * @param singleLine true = single-line text field; false = multi-line text area
+ */
+ public static int createEditText(final String placeholder, final boolean singleLine) {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ EditText et = new EditText(ctx());
+ et.setHint(placeholder != null ? placeholder : "");
+ if (singleLine) {
+ et.setInputType(InputType.TYPE_CLASS_TEXT
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ et.setMaxLines(1);
+ et.setSingleLine(true);
+ } else {
+ et.setInputType(InputType.TYPE_CLASS_TEXT
+ | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
+ et.setMinLines(3);
+ et.setSingleLine(false);
+ et.setGravity(Gravity.TOP | Gravity.START);
+ }
+ et.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ return allocSlot(et);
+ }
+ });
+ }
+
+ /**
+ * Create an ImageView, loading from a file path via BitmapFactory.
+ * If path is null/empty the ImageView is created with no image.
+ */
+ public static int createImageView(final String path) {
+ return runSyncInt(new IntSupplier() {
+ @Override public int get() {
+ ImageView iv = new ImageView(ctx());
+ iv.setScaleType(ImageView.ScaleType.FIT_CENTER);
+ iv.setAdjustViewBounds(true);
+ if (path != null && !path.isEmpty()) {
+ Bitmap bmp = BitmapFactory.decodeFile(path);
+ if (bmp != null) {
+ iv.setImageBitmap(bmp);
+ } else {
+ android.util.Log.w("ElBridge",
+ "createImageView: failed to decode " + path);
+ }
+ }
+ iv.setLayoutParams(new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT));
+ return allocSlot(iv);
+ }
+ });
+ }
+
+ /* ── Window operations ───────────────────────────────────────────────── */
+
+ /** Set the Activity's content view to the view at slot. */
+ public static void setContentView(final int slot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v != null && sActivity != null) {
+ sActivity.setContentView(v);
+ }
+ }
+ });
+ }
+
+ /** Set the Activity title. */
+ public static void setTitle(final String title) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ if (sActivity != null) {
+ sActivity.setTitle(title != null ? title : "");
+ }
+ }
+ });
+ }
+
+ /* ── Tree operations ─────────────────────────────────────────────────── */
+
+ /**
+ * Add child view to parent view.
+ * LinearLayout: child added as arranged child with spacing margin.
+ * ScrollView: child replaces current document view.
+ * FrameLayout / other ViewGroup: plain addView.
+ */
+ public static void addChild(final int parentSlot, final int childSlot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View parent = getView(parentSlot);
+ View child = getView(childSlot);
+ if (parent == null || child == null) return;
+
+ /* Remove child from existing parent first. */
+ if (child.getParent() instanceof ViewGroup) {
+ ((ViewGroup) child.getParent()).removeView(child);
+ }
+
+ if (parent instanceof LinearLayout) {
+ LinearLayout ll = (LinearLayout) parent;
+ Object tag = ll.getTag(R_TAG_SPACING);
+ int spacing = (tag instanceof Integer) ? (Integer) tag : 0;
+ LinearLayout.LayoutParams lp;
+ Object existingLp = child.getLayoutParams();
+ if (existingLp instanceof LinearLayout.LayoutParams) {
+ lp = (LinearLayout.LayoutParams) existingLp;
+ } else {
+ lp = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ }
+ /* Apply spacing as margin on the leading/top edge (after first child). */
+ if (ll.getChildCount() > 0 && spacing > 0) {
+ int px = dpToPx(spacing);
+ if (ll.getOrientation() == LinearLayout.VERTICAL) {
+ lp.topMargin = px;
+ } else {
+ lp.leftMargin = px;
+ }
+ }
+ child.setLayoutParams(lp);
+ ll.addView(child);
+ } else if (parent instanceof ScrollView) {
+ ScrollView sv = (ScrollView) parent;
+ sv.removeAllViews();
+ sv.addView(child);
+ } else if (parent instanceof ViewGroup) {
+ ((ViewGroup) parent).addView(child);
+ }
+ }
+ });
+ }
+
+ /** Remove child from its parent. */
+ public static void removeChild(final int parentSlot, final int childSlot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View parent = getView(parentSlot);
+ View child = getView(childSlot);
+ if (parent instanceof ViewGroup && child != null) {
+ ((ViewGroup) parent).removeView(child);
+ }
+ }
+ });
+ }
+
+ /** Remove the view from its parent and release the slot. */
+ public static void destroyView(final int slot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ if (v.getParent() instanceof ViewGroup) {
+ ((ViewGroup) v.getParent()).removeView(v);
+ }
+ sViews[slot] = null;
+ }
+ });
+ }
+
+ /* ── Property setters ────────────────────────────────────────────────── */
+
+ public static void setText(final int slot, final String text) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ String s = text != null ? text : "";
+ if (v instanceof EditText) {
+ ((EditText) v).setText(s);
+ } else if (v instanceof Button) {
+ ((Button) v).setText(s);
+ } else if (v instanceof TextView) {
+ ((TextView) v).setText(s);
+ }
+ }
+ });
+ }
+
+ public static String getText(final int slot) {
+ final String[] result = { "" };
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v instanceof TextView) {
+ CharSequence cs = ((TextView) v).getText();
+ result[0] = cs != null ? cs.toString() : "";
+ }
+ }
+ });
+ return result[0];
+ }
+
+ /** Set foreground text color. Components in [0,1]. */
+ public static void setTextColor(final int slot, final float r, final float g,
+ final float b, final float a) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v instanceof TextView) {
+ ((TextView) v).setTextColor(floatToArgb(r, g, b, a));
+ }
+ }
+ });
+ }
+
+ /** Set background color using a GradientDrawable so corner radius is preserved. */
+ public static void setBackgroundColor(final int slot, final float r, final float g,
+ final float b, final float a) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ ensureGradientBackground(v);
+ GradientDrawable gd = (GradientDrawable) v.getBackground();
+ gd.setColor(floatToArgb(r, g, b, a));
+ }
+ });
+ }
+
+ /**
+ * Set font family and size.
+ * family: "system" or null → system default; otherwise tries to load by name.
+ * bold: if true uses Typeface.BOLD.
+ */
+ public static void setFont(final int slot, final String family,
+ final int sizeSp, final boolean bold) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (!(v instanceof TextView)) return;
+ TextView tv = (TextView) v;
+ Typeface tf;
+ if (family != null && !family.isEmpty()
+ && !family.equals("system")) {
+ Typeface base = Typeface.create(family,
+ bold ? Typeface.BOLD : Typeface.NORMAL);
+ tf = (base != null) ? base
+ : Typeface.defaultFromStyle(bold ? Typeface.BOLD : Typeface.NORMAL);
+ } else {
+ tf = Typeface.defaultFromStyle(bold ? Typeface.BOLD : Typeface.NORMAL);
+ }
+ tv.setTypeface(tf);
+ tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, sizeSp);
+ }
+ });
+ }
+
+ /** Set padding in dp. */
+ public static void setPadding(final int slot, final int top, final int right,
+ final int bottom, final int left) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v != null) {
+ v.setPadding(dpToPx(left), dpToPx(top), dpToPx(right), dpToPx(bottom));
+ }
+ }
+ });
+ }
+
+ /** Set explicit width in dp. Passes MATCH_PARENT for negative values. */
+ public static void setWidth(final int slot, final int widthDp) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ ViewGroup.LayoutParams lp = v.getLayoutParams();
+ if (lp == null) lp = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ lp.width = widthDp < 0
+ ? ViewGroup.LayoutParams.MATCH_PARENT
+ : dpToPx(widthDp);
+ v.setLayoutParams(lp);
+ }
+ });
+ }
+
+ /** Set explicit height in dp. */
+ public static void setHeight(final int slot, final int heightDp) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ ViewGroup.LayoutParams lp = v.getLayoutParams();
+ if (lp == null) lp = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ lp.height = heightDp < 0
+ ? ViewGroup.LayoutParams.MATCH_PARENT
+ : dpToPx(heightDp);
+ v.setLayoutParams(lp);
+ }
+ });
+ }
+
+ /**
+ * Set flex weight on a child of a LinearLayout.
+ * flex > 0 → weight = flex, width/height = 0dp (expand).
+ * flex == 0 → weight = 0, wrap_content (shrink to content).
+ */
+ public static void setFlex(final int slot, final int flex) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ ViewGroup.LayoutParams lp = v.getLayoutParams();
+ if (lp instanceof LinearLayout.LayoutParams) {
+ LinearLayout.LayoutParams llp = (LinearLayout.LayoutParams) lp;
+ if (flex > 0) {
+ llp.weight = (float) flex;
+ /* Determine orientation from parent to set 0dp on the right axis. */
+ if (v.getParent() instanceof LinearLayout) {
+ LinearLayout parent = (LinearLayout) v.getParent();
+ if (parent.getOrientation() == LinearLayout.VERTICAL) {
+ llp.height = 0;
+ } else {
+ llp.width = 0;
+ }
+ }
+ } else {
+ llp.weight = 0f;
+ }
+ v.setLayoutParams(llp);
+ }
+ }
+ });
+ }
+
+ /** Set corner radius in dp using a GradientDrawable background. */
+ public static void setCornerRadius(final int slot, final float radiusDp) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ ensureGradientBackground(v);
+ GradientDrawable gd = (GradientDrawable) v.getBackground();
+ gd.setCornerRadius(dpToPxF(radiusDp));
+ }
+ });
+ }
+
+ public static void setEnabled(final int slot, final boolean enabled) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v != null) v.setEnabled(enabled);
+ }
+ });
+ }
+
+ /**
+ * Show or hide a view.
+ * @param visible true = VISIBLE, false = GONE (matches AppKit setHidden semantics)
+ */
+ public static void setVisibility(final int slot, final boolean visible) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v != null) v.setVisibility(visible ? View.VISIBLE : View.GONE);
+ }
+ });
+ }
+
+ /* ── Event listener registration ─────────────────────────────────────── */
+
+ /** Register an OnClickListener that calls back into C nativeOnClick. */
+ public static void setOnClickListener(final int slot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (v == null) return;
+ final int capturedSlot = slot;
+ v.setOnClickListener(new View.OnClickListener() {
+ @Override public void onClick(View view) {
+ nativeOnClick(capturedSlot);
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Register a TextWatcher on an EditText that calls back nativeOnChange
+ * for every text change.
+ */
+ public static void setOnChangeListener(final int slot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (!(v instanceof EditText)) return;
+ final int capturedSlot = slot;
+ ((EditText) v).addTextChangedListener(new TextWatcher() {
+ @Override public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {}
+ @Override public void onTextChanged(CharSequence s, int start,
+ int before, int count) {}
+ @Override public void afterTextChanged(Editable s) {
+ nativeOnChange(capturedSlot, s != null ? s.toString() : "");
+ }
+ });
+ }
+ });
+ }
+
+ /**
+ * Register an OnEditorActionListener on a single-line EditText that calls
+ * nativeOnSubmit when the user presses the action/enter key.
+ */
+ public static void setOnSubmitListener(final int slot) {
+ runSync(new Runnable() {
+ @Override public void run() {
+ View v = getView(slot);
+ if (!(v instanceof EditText)) return;
+ final int capturedSlot = slot;
+ ((EditText) v).setOnEditorActionListener(
+ new TextView.OnEditorActionListener() {
+ @Override
+ public boolean onEditorAction(TextView tv, int actionId,
+ android.view.KeyEvent event) {
+ nativeOnSubmit(capturedSlot,
+ tv.getText() != null ? tv.getText().toString() : "");
+ return true;
+ }
+ });
+ }
+ });
+ }
+
+ /* ── Internal helpers ─────────────────────────────────────────────────── */
+
+ /*
+ * Tag key used to stash the spacing value on LinearLayouts so addChild
+ * can apply the correct margin between children.
+ * We use a stable integer resource-id-like value; because we do not have
+ * a resources file here we use View.generateViewId() lazily.
+ */
+ private static int sSpacingTagKey = 0;
+
+ private static int R_TAG_SPACING;
+ static {
+ R_TAG_SPACING = View.generateViewId();
+ }
+
+ /** Convert dp to pixels using the Activity's display metrics. */
+ private static int dpToPx(float dp) {
+ if (sActivity == null) return (int) dp;
+ float density = sActivity.getResources().getDisplayMetrics().density;
+ return Math.round(dp * density);
+ }
+
+ private static float dpToPxF(float dp) {
+ if (sActivity == null) return dp;
+ float density = sActivity.getResources().getDisplayMetrics().density;
+ return dp * density;
+ }
+
+ /** Convert RGBA float components (0–1) to an Android ARGB int. */
+ private static int floatToArgb(float r, float g, float b, float a) {
+ int ai = Math.round(a * 255f);
+ int ri = Math.round(r * 255f);
+ int gi = Math.round(g * 255f);
+ int bi = Math.round(b * 255f);
+ return Color.argb(ai, ri, gi, bi);
+ }
+
+ /**
+ * Ensure the view has a GradientDrawable background so that both color
+ * and corner radius can be set independently. If the current background
+ * is already a GradientDrawable it is reused; otherwise a new transparent
+ * one is installed.
+ */
+ private static void ensureGradientBackground(View v) {
+ if (!(v.getBackground() instanceof GradientDrawable)) {
+ GradientDrawable gd = new GradientDrawable();
+ gd.setColor(Color.TRANSPARENT);
+ v.setBackground(gd);
+ }
+ }
+}
diff --git a/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/MainActivity.java b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/MainActivity.java
new file mode 100644
index 0000000..47eec0e
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/java/com/neuron/el/MainActivity.java
@@ -0,0 +1,38 @@
+package com.neuron.el;
+
+import android.app.Activity;
+import android.os.Bundle;
+
+/**
+ * MainActivity — entry point for native-hello-android.
+ *
+ * Loads the el native shared library, initialises ElBridge with the Activity
+ * reference (required before any widget operations), then hands control to the
+ * compiled el program via nativeMain().
+ *
+ * The el boot sequence (native_init → window_from_manifest → app_build →
+ * window_show) runs inside nativeMain. __native_run_loop is a no-op on Android;
+ * the Activity lifecycle owns the UI thread after onCreate returns.
+ */
+public class MainActivity extends Activity {
+
+ static {
+ System.loadLibrary("elnative");
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // Register this Activity with the bridge BEFORE nativeMain so that
+ // el_android_init can look up ElBridge methods and __native_init works.
+ ElBridge.init(this);
+ // Run the compiled el program.
+ nativeMain();
+ }
+
+ /**
+ * Implemented in el_android.c as Java_com_neuron_el_MainActivity_nativeMain.
+ * Calls the el program's main(), which runs the full boot sequence.
+ */
+ private native void nativeMain();
+}
diff --git a/ui/examples/native-hello-android/app/src/main/jni/CMakeLists.txt b/ui/examples/native-hello-android/app/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000..fbc3dea
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/CMakeLists.txt
@@ -0,0 +1,57 @@
+cmake_minimum_required(VERSION 3.22.1)
+project("elnative")
+
+#
+# CMakeLists.txt for native-hello-android
+#
+# Sources:
+# el_android.c — Android JNI widget bridge
+# el_seed.c — OS-boundary __-prefixed primitives
+# el_runtime.c — High-level el builtins (str_len, json_*, etc.)
+# el_native_vessel.c — el-native vessel (compiled from vessels/el-native/src/main.el)
+# native_hello.c — App entry point (compiled from examples/native-hello/src/main.el)
+#
+# el_runtime.c and el_seed.c both define __-prefixed symbols. On Android the
+# linker accepts duplicate weak symbols; the shared library loads one copy.
+# The EL_TARGET_ANDROID guard in el_android.c ensures only the Android widget
+# backend is compiled in.
+#
+
+add_library(
+ elnative
+ SHARED
+
+ # native_hello.c listed first so its main() takes precedence over the
+ # vessel's main() when --allow-multiple-definition is in effect.
+ native_hello.c
+ el_native_vessel.c
+ el_android.c
+ el_seed.c
+ el_runtime.c
+)
+
+# EL_TARGET_ANDROID activates the Android JNI widget backend in el_android.c
+# and the corresponding guards in el_seed.c / el_native_target.h.
+target_compile_definitions(elnative PRIVATE
+ EL_TARGET_ANDROID
+)
+
+target_include_directories(elnative PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+# el_runtime.c and el_seed.c both define __-prefixed OS-boundary symbols.
+# el_runtime.c's copies are weaker definitions; allow-multiple-definition lets
+# the linker pick one copy silently (el_seed.c listed later = its copy wins
+# in the Android linker's right-to-left resolution order).
+target_link_options(elnative PRIVATE
+ "-Wl,--allow-multiple-definition"
+)
+
+find_library(log-lib log)
+find_library(android-lib android)
+
+target_link_libraries(elnative
+ ${log-lib}
+ ${android-lib}
+)
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_android.c b/ui/examples/native-hello-android/app/src/main/jni/el_android.c
new file mode 100644
index 0000000..5f629ba
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_android.c
@@ -0,0 +1,964 @@
+/*
+ * el_android.c — Android JNI backend for the el native widget system.
+ *
+ * This file implements the Android widget layer that el_seed.c calls through
+ * to when EL_TARGET_ANDROID is defined. It is the exact Android counterpart
+ * to el_appkit.m and presents the same C API surface.
+ *
+ * Architecture:
+ * el program (el code)
+ * → __widget_* C builtins in el_seed.c
+ * → el_android_* C-callable functions declared here
+ * → ElBridge static methods in Java via JNI
+ * → android.view.View subclasses on the UI thread
+ *
+ * Widget handles: every widget (window root, view, control) is assigned an
+ * int64_t slot index into view_slots[]. The el program holds these as opaque
+ * Int values. Slot 0 is never valid (null handle = -1 convention).
+ *
+ * Threading: Android requires all UI operations to run on the main (UI) thread.
+ * Every JNI call that mutates a View is dispatched through
+ * Activity.runOnUiThread(Runnable) if the current thread is not the UI thread.
+ * el_android_run_loop is a no-op — Android lifecycle is driven by the Activity.
+ *
+ * Callback mechanism: when a widget fires an event Java calls
+ * nativeOnClick / nativeOnChange / nativeOnSubmit
+ * The C side looks up the registered El function name for that slot, then:
+ * dlsym(RTLD_DEFAULT, fn_name)(widget_handle, event_data_string)
+ * This matches the __thread_create pattern in el_seed.c exactly.
+ *
+ * Compile / link (as part of libelruntime.so):
+ * Compiled by the Android Gradle NDK build system with -DEL_TARGET_ANDROID.
+ * Link flags: -landroid -llog -ldl
+ *
+ * Java companion: ElBridge.java in the same directory must be compiled into
+ * the Android application's APK (package com.neuron.el).
+ */
+
+#ifdef EL_TARGET_ANDROID
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include "el_runtime.h"
+
+/* ── Logging ─────────────────────────────────────────────────────────────── */
+
+#define EL_TAG "ElAndroid"
+#define EL_LOGI(...) __android_log_print(ANDROID_LOG_INFO, EL_TAG, __VA_ARGS__)
+#define EL_LOGW(...) __android_log_print(ANDROID_LOG_WARN, EL_TAG, __VA_ARGS__)
+#define EL_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, EL_TAG, __VA_ARGS__)
+
+/* ── JNI global state ────────────────────────────────────────────────────── */
+
+static JavaVM *g_jvm = NULL;
+static jobject g_activity = NULL; /* global ref to Activity */
+static jclass g_bridge_class = NULL; /* global ref to ElBridge class */
+
+/* Cached method IDs on ElBridge — filled in el_android_init(). */
+static jmethodID g_mid_createLinearLayout = NULL;
+static jmethodID g_mid_createFrameLayout = NULL;
+static jmethodID g_mid_createScrollView = NULL;
+static jmethodID g_mid_createTextView = NULL;
+static jmethodID g_mid_createButton = NULL;
+static jmethodID g_mid_createEditText = NULL;
+static jmethodID g_mid_createImageView = NULL;
+static jmethodID g_mid_setContentView = NULL;
+static jmethodID g_mid_setTitle = NULL;
+static jmethodID g_mid_addChild = NULL;
+static jmethodID g_mid_removeChild = NULL;
+static jmethodID g_mid_destroyView = NULL;
+static jmethodID g_mid_setText = NULL;
+static jmethodID g_mid_getText = NULL;
+static jmethodID g_mid_setTextColor = NULL;
+static jmethodID g_mid_setBackgroundColor = NULL;
+static jmethodID g_mid_setFont = NULL;
+static jmethodID g_mid_setPadding = NULL;
+static jmethodID g_mid_setWidth = NULL;
+static jmethodID g_mid_setHeight = NULL;
+static jmethodID g_mid_setFlex = NULL;
+static jmethodID g_mid_setCornerRadius = NULL;
+static jmethodID g_mid_setEnabled = NULL;
+static jmethodID g_mid_setVisibility = NULL;
+static jmethodID g_mid_setOnClickListener = NULL;
+static jmethodID g_mid_setOnChangeListener = NULL;
+static jmethodID g_mid_setOnSubmitListener = NULL;
+static jmethodID g_mid_runOnUiThread = NULL; /* Activity.runOnUiThread */
+
+/* ── Widget table ─────────────────────────────────────────────────────────── */
+
+#define EL_ANDROID_MAX_WIDGETS 4096
+
+typedef enum {
+ EL_WIDGET_FREE = 0,
+ EL_WIDGET_WINDOW = 1,
+ EL_WIDGET_VSTACK = 2,
+ EL_WIDGET_HSTACK = 3,
+ EL_WIDGET_ZSTACK = 4,
+ EL_WIDGET_SCROLL = 5,
+ EL_WIDGET_LABEL = 6,
+ EL_WIDGET_BUTTON = 7,
+ EL_WIDGET_TEXTFIELD = 8,
+ EL_WIDGET_TEXTAREA = 9,
+ EL_WIDGET_IMAGE = 10,
+} ElWidgetKind;
+
+typedef struct {
+ ElWidgetKind kind;
+ jint slot; /* Java-side slot index (matches C index) */
+ char *cb_click; /* El function name for click / submit events */
+ char *cb_change; /* El function name for value-change events */
+} ElWidget;
+
+static ElWidget _el_widgets[EL_ANDROID_MAX_WIDGETS];
+
+static int64_t el_widget_alloc(ElWidgetKind kind, jint slot) {
+ for (int i = 1; i < EL_ANDROID_MAX_WIDGETS; i++) {
+ if (_el_widgets[i].kind == EL_WIDGET_FREE) {
+ _el_widgets[i].kind = kind;
+ _el_widgets[i].slot = slot;
+ _el_widgets[i].cb_click = NULL;
+ _el_widgets[i].cb_change = NULL;
+ return (int64_t)i;
+ }
+ }
+ EL_LOGE("el_widget_alloc: slot table full");
+ return -1;
+}
+
+static ElWidget *el_widget_get(int64_t handle) {
+ if (handle <= 0 || handle >= EL_ANDROID_MAX_WIDGETS) return NULL;
+ if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
+ return &_el_widgets[handle];
+}
+
+static void el_widget_free(int64_t handle) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ w->kind = EL_WIDGET_FREE;
+ w->slot = -1;
+ free(w->cb_click); w->cb_click = NULL;
+ free(w->cb_change); w->cb_change = NULL;
+}
+
+/* ── JNI environment helpers ─────────────────────────────────────────────── */
+
+/*
+ * Obtain a JNIEnv for the calling thread. Attaches the thread to the JVM if
+ * needed (detaches in el_jni_detach_if_attached — call in pairs).
+ */
+static int g_was_attached = 0; /* thread-local would be cleaner but this is
+ safe for single-threaded el programs */
+
+static JNIEnv *el_jni_env(void) {
+ if (!g_jvm) return NULL;
+ JNIEnv *env = NULL;
+ jint rc = (*g_jvm)->GetEnv(g_jvm, (void **)&env, JNI_VERSION_1_6);
+ if (rc == JNI_OK) { g_was_attached = 0; return env; }
+ if (rc == JNI_EDETACHED) {
+ if ((*g_jvm)->AttachCurrentThread(g_jvm, &env, NULL) == JNI_OK) {
+ g_was_attached = 1;
+ return env;
+ }
+ }
+ EL_LOGE("el_jni_env: failed to obtain JNIEnv");
+ return NULL;
+}
+
+static void el_jni_detach_if_attached(void) {
+ if (g_was_attached && g_jvm) {
+ (*g_jvm)->DetachCurrentThread(g_jvm);
+ g_was_attached = 0;
+ }
+}
+
+/* ── UI-thread dispatch ──────────────────────────────────────────────────── */
+/*
+ * Most ElBridge static methods already dispatch to the UI thread internally
+ * (they call Activity.runOnUiThread). The helper below is available for
+ * cases where the caller needs to be sure the call has completed before
+ * returning (ElBridge methods marked "sync" use a CountDownLatch internally).
+ *
+ * For the current implementation we call ElBridge methods directly; ElBridge
+ * itself marshals to the UI thread via Activity.runOnUiThread + latch.
+ * This keeps the C side simple and mirrors the AppKit dispatch_sync pattern.
+ */
+
+/* ── JNI_OnLoad ──────────────────────────────────────────────────────────── */
+
+JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
+ (void)reserved;
+ g_jvm = vm;
+ EL_LOGI("JNI_OnLoad: el Android bridge loaded");
+ return JNI_VERSION_1_6;
+}
+
+/* ── el_android_init ─────────────────────────────────────────────────────── */
+/*
+ * Called from __native_init(). The Activity must have already called
+ * ElBridge.registerActivity(activity) from Java before this runs, which sets
+ * g_activity via the nativeRegisterActivity JNI method below.
+ *
+ * Caches all method IDs used later so individual widget calls avoid repeated
+ * FindClass / GetStaticMethodID lookups.
+ */
+void el_android_init(void) {
+ static int done = 0;
+ if (done) return;
+ done = 1;
+
+ JNIEnv *env = el_jni_env();
+ if (!env) { EL_LOGE("el_android_init: no JNIEnv"); return; }
+
+ jclass cls = (*env)->FindClass(env, "com/neuron/el/ElBridge");
+ if (!cls) { EL_LOGE("el_android_init: ElBridge class not found"); return; }
+ g_bridge_class = (*env)->NewGlobalRef(env, cls);
+ (*env)->DeleteLocalRef(env, cls);
+
+#define CACHE_STATIC(var, name, sig) \
+ var = (*env)->GetStaticMethodID(env, g_bridge_class, name, sig); \
+ if (!var) EL_LOGW("el_android_init: method not found: %s %s", name, sig)
+
+ CACHE_STATIC(g_mid_createLinearLayout, "createLinearLayout", "(II)I");
+ CACHE_STATIC(g_mid_createFrameLayout, "createFrameLayout", "()I");
+ CACHE_STATIC(g_mid_createScrollView, "createScrollView", "()I");
+ CACHE_STATIC(g_mid_createTextView, "createTextView", "(Ljava/lang/String;)I");
+ CACHE_STATIC(g_mid_createButton, "createButton", "(Ljava/lang/String;)I");
+ CACHE_STATIC(g_mid_createEditText, "createEditText", "(Ljava/lang/String;Z)I");
+ CACHE_STATIC(g_mid_createImageView, "createImageView", "(Ljava/lang/String;)I");
+ CACHE_STATIC(g_mid_setContentView, "setContentView", "(I)V");
+ CACHE_STATIC(g_mid_setTitle, "setTitle", "(Ljava/lang/String;)V");
+ CACHE_STATIC(g_mid_addChild, "addChild", "(II)V");
+ CACHE_STATIC(g_mid_removeChild, "removeChild", "(II)V");
+ CACHE_STATIC(g_mid_destroyView, "destroyView", "(I)V");
+ CACHE_STATIC(g_mid_setText, "setText", "(ILjava/lang/String;)V");
+ CACHE_STATIC(g_mid_getText, "getText", "(I)Ljava/lang/String;");
+ CACHE_STATIC(g_mid_setTextColor, "setTextColor", "(IFFFF)V");
+ CACHE_STATIC(g_mid_setBackgroundColor, "setBackgroundColor", "(IFFFF)V");
+ CACHE_STATIC(g_mid_setFont, "setFont", "(ILjava/lang/String;IZ)V");
+ CACHE_STATIC(g_mid_setPadding, "setPadding", "(IIIII)V");
+ CACHE_STATIC(g_mid_setWidth, "setWidth", "(II)V");
+ CACHE_STATIC(g_mid_setHeight, "setHeight", "(II)V");
+ CACHE_STATIC(g_mid_setFlex, "setFlex", "(II)V");
+ CACHE_STATIC(g_mid_setCornerRadius, "setCornerRadius", "(IF)V");
+ CACHE_STATIC(g_mid_setEnabled, "setEnabled", "(IZ)V");
+ CACHE_STATIC(g_mid_setVisibility, "setVisibility", "(IZ)V");
+ CACHE_STATIC(g_mid_setOnClickListener, "setOnClickListener", "(I)V");
+ CACHE_STATIC(g_mid_setOnChangeListener, "setOnChangeListener", "(I)V");
+ CACHE_STATIC(g_mid_setOnSubmitListener, "setOnSubmitListener", "(I)V");
+#undef CACHE_STATIC
+
+ el_jni_detach_if_attached();
+ EL_LOGI("el_android_init: complete");
+}
+
+/* ── JNI: Activity registration ─────────────────────────────────────────── */
+
+/*
+ * Called from Java: ElBridge.registerActivity(activity) calls back here.
+ * Stores a global reference to the Activity so C code can dispatch to it.
+ */
+JNIEXPORT void JNICALL
+Java_com_neuron_el_ElBridge_nativeRegisterActivity(JNIEnv *env, jclass cls,
+ jobject activity) {
+ (void)cls;
+ if (g_activity) {
+ (*env)->DeleteGlobalRef(env, g_activity);
+ g_activity = NULL;
+ }
+ if (activity) {
+ g_activity = (*env)->NewGlobalRef(env, activity);
+ EL_LOGI("nativeRegisterActivity: activity registered");
+ }
+}
+
+/* ── El callback invocation ──────────────────────────────────────────────── */
+/*
+ * Invoke an El callback by symbol name.
+ * Signature matches AppKit: fn(handle: Int, data: String) -> Void
+ * compiled to: void fn(el_val_t handle, el_val_t data)
+ */
+typedef void (*ElCb2)(int64_t handle, int64_t data);
+
+static void el_android_invoke_cb(const char *fn_name, int64_t handle,
+ const char *data) {
+ if (!fn_name || !*fn_name) return;
+ void *sym = dlsym(RTLD_DEFAULT, fn_name);
+ if (!sym) { EL_LOGW("invoke_cb: symbol not found: %s", fn_name); return; }
+ ElCb2 fn = (ElCb2)sym;
+ fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
+}
+
+/* ── JNI: callbacks from Java → C ───────────────────────────────────────── */
+
+JNIEXPORT void JNICALL
+Java_com_neuron_el_ElBridge_nativeOnClick(JNIEnv *env, jclass cls, jint slot) {
+ (void)env; (void)cls;
+ int64_t handle = (int64_t)slot;
+ ElWidget *w = el_widget_get(handle);
+ if (w && w->cb_click) {
+ el_android_invoke_cb(w->cb_click, handle, "");
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_neuron_el_ElBridge_nativeOnChange(JNIEnv *env, jclass cls,
+ jint slot, jstring text) {
+ (void)cls;
+ int64_t handle = (int64_t)slot;
+ ElWidget *w = el_widget_get(handle);
+ if (w && w->cb_change) {
+ const char *ctext = text ? (*env)->GetStringUTFChars(env, text, NULL) : "";
+ el_android_invoke_cb(w->cb_change, handle, ctext);
+ if (text) (*env)->ReleaseStringUTFChars(env, text, ctext);
+ }
+}
+
+JNIEXPORT void JNICALL
+Java_com_neuron_el_ElBridge_nativeOnSubmit(JNIEnv *env, jclass cls,
+ jint slot, jstring text) {
+ (void)cls;
+ int64_t handle = (int64_t)slot;
+ ElWidget *w = el_widget_get(handle);
+ if (w && w->cb_click) { /* submit stored in cb_click, same as AppKit */
+ const char *ctext = text ? (*env)->GetStringUTFChars(env, text, NULL) : "";
+ el_android_invoke_cb(w->cb_click, handle, ctext);
+ if (text) (*env)->ReleaseStringUTFChars(env, text, ctext);
+ }
+}
+
+/* ── Helper: jstring from C string ──────────────────────────────────────── */
+
+static jstring el_jstr(JNIEnv *env, const char *s) {
+ return (*env)->NewStringUTF(env, s ? s : "");
+}
+
+/* ── Window ──────────────────────────────────────────────────────────────── */
+
+/*
+ * el_android_window_create — on Android a "window" is the root LinearLayout
+ * set as the Activity's content view. We create a vertical LinearLayout and
+ * store it. el_android_window_show calls setContentView on the Activity.
+ */
+int64_t el_android_window_create(const char *title, int width, int height,
+ int min_width, int min_height) {
+ (void)width; (void)height; (void)min_width; (void)min_height;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+
+ /* VERTICAL LinearLayout with no spacing (spacing added via margins in Java) */
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createLinearLayout,
+ (jint)1 /* VERTICAL */, (jint)0);
+ if ((*env)->ExceptionCheck(env)) {
+ (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1;
+ }
+
+ /* Set activity title */
+ if (g_mid_setTitle && title) {
+ jstring jtitle = el_jstr(env, title);
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setTitle, jtitle);
+ (*env)->DeleteLocalRef(env, jtitle);
+ }
+
+ int64_t handle = el_widget_alloc(EL_WIDGET_WINDOW, (int)slot);
+ el_jni_detach_if_attached();
+ return handle;
+}
+
+void el_android_window_show(int64_t handle) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w || w->kind != EL_WIDGET_WINDOW) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setContentView,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_window_set_title(int64_t handle, const char *title) {
+ (void)handle;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ jstring jtitle = el_jstr(env, title);
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setTitle, jtitle);
+ (*env)->DeleteLocalRef(env, jtitle);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+/* ── Layout containers ───────────────────────────────────────────────────── */
+
+int64_t el_android_vstack_create(int spacing) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createLinearLayout,
+ (jint)1 /* VERTICAL */, (jint)spacing);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_VSTACK, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_hstack_create(int spacing) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createLinearLayout,
+ (jint)0 /* HORIZONTAL */, (jint)spacing);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_HSTACK, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_zstack_create(void) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createFrameLayout);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_ZSTACK, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_scroll_create(void) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createScrollView);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_SCROLL, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+/* ── Widget factories ─────────────────────────────────────────────────────── */
+
+int64_t el_android_label_create(const char *text) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jstring jt = el_jstr(env, text);
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createTextView, jt);
+ (*env)->DeleteLocalRef(env, jt);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_LABEL, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_button_create(const char *label) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jstring jl = el_jstr(env, label);
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createButton, jl);
+ (*env)->DeleteLocalRef(env, jl);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_BUTTON, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_text_field_create(const char *placeholder) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jstring jp = el_jstr(env, placeholder);
+ /* singleLine = true */
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createEditText, jp, (jboolean)JNI_TRUE);
+ (*env)->DeleteLocalRef(env, jp);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_TEXTFIELD, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_text_area_create(const char *placeholder) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jstring jp = el_jstr(env, placeholder);
+ /* singleLine = false → multiline EditText */
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createEditText, jp, (jboolean)JNI_FALSE);
+ (*env)->DeleteLocalRef(env, jp);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_TEXTAREA, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+int64_t el_android_image_create(const char *path) {
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return -1;
+ jstring jp = el_jstr(env, path);
+ jint slot = (*env)->CallStaticIntMethod(env, g_bridge_class,
+ g_mid_createImageView, jp);
+ (*env)->DeleteLocalRef(env, jp);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return -1; }
+ int64_t h = el_widget_alloc(EL_WIDGET_IMAGE, (int)slot);
+ el_jni_detach_if_attached();
+ return h;
+}
+
+/* ── Widget property setters ─────────────────────────────────────────────── */
+
+void el_android_widget_set_text(int64_t handle, const char *text) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ jstring jt = el_jstr(env, text);
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setText,
+ (jint)w->slot, jt);
+ (*env)->DeleteLocalRef(env, jt);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+const char *el_android_widget_get_text(int64_t handle) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return "";
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return "";
+ jstring js = (jstring)(*env)->CallStaticObjectMethod(env, g_bridge_class,
+ g_mid_getText,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); el_jni_detach_if_attached(); return ""; }
+ const char *result = "";
+ if (js) {
+ const char *cstr = (*env)->GetStringUTFChars(env, js, NULL);
+ result = cstr ? strdup(cstr) : "";
+ if (cstr) (*env)->ReleaseStringUTFChars(env, js, cstr);
+ (*env)->DeleteLocalRef(env, js);
+ }
+ el_jni_detach_if_attached();
+ return result;
+}
+
+void el_android_widget_set_color(int64_t handle, float r, float g, float b, float a) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setTextColor,
+ (jint)w->slot, (jfloat)r, (jfloat)g,
+ (jfloat)b, (jfloat)a);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_bg_color(int64_t handle, float r, float g, float b, float a) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setBackgroundColor,
+ (jint)w->slot, (jfloat)r, (jfloat)g,
+ (jfloat)b, (jfloat)a);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_font(int64_t handle, const char *family, int size, int bold) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ jstring jfam = el_jstr(env, family);
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setFont,
+ (jint)w->slot, jfam, (jint)size,
+ (jboolean)(bold ? JNI_TRUE : JNI_FALSE));
+ (*env)->DeleteLocalRef(env, jfam);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_padding(int64_t handle, int top, int right, int bottom, int left) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setPadding,
+ (jint)w->slot, (jint)top, (jint)right,
+ (jint)bottom, (jint)left);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_width(int64_t handle, int width) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setWidth,
+ (jint)w->slot, (jint)width);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_height(int64_t handle, int height) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setHeight,
+ (jint)w->slot, (jint)height);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_flex(int64_t handle, int flex) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setFlex,
+ (jint)w->slot, (jint)flex);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_corner_radius(int64_t handle, int radius) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setCornerRadius,
+ (jint)w->slot, (jfloat)radius);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_disabled(int64_t handle, int disabled) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setEnabled,
+ (jint)w->slot,
+ (jboolean)(disabled ? JNI_FALSE : JNI_TRUE));
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_set_hidden(int64_t handle, int hidden) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ /* visible=true means NOT hidden */
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setVisibility,
+ (jint)w->slot,
+ (jboolean)(hidden ? JNI_FALSE : JNI_TRUE));
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+/* ── Child management ─────────────────────────────────────────────────────── */
+
+void el_android_widget_add_child(int64_t parent, int64_t child) {
+ ElWidget *pw = el_widget_get(parent);
+ ElWidget *cw = el_widget_get(child);
+ if (!pw || !cw) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_addChild,
+ (jint)pw->slot, (jint)cw->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_remove_child(int64_t parent, int64_t child) {
+ ElWidget *pw = el_widget_get(parent);
+ ElWidget *cw = el_widget_get(child);
+ if (!pw || !cw) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_removeChild,
+ (jint)pw->slot, (jint)cw->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+/* ── Event registration ───────────────────────────────────────────────────── */
+
+void el_android_widget_on_click(int64_t handle, const char *fn_name) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_click);
+ w->cb_click = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+ if (!w->cb_click) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setOnClickListener,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_on_change(int64_t handle, const char *fn_name) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_change);
+ w->cb_change = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+ if (!w->cb_change) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setOnChangeListener,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+void el_android_widget_on_submit(int64_t handle, const char *fn_name) {
+ /* Submit stored in cb_click, same as AppKit. */
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_click);
+ w->cb_click = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+ if (!w->cb_click) return;
+ JNIEnv *env = el_jni_env();
+ if (!env || !g_bridge_class) return;
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_setOnSubmitListener,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ el_jni_detach_if_attached();
+}
+
+/* ── Widget destroy ───────────────────────────────────────────────────────── */
+
+void el_android_widget_destroy(int64_t handle) {
+ ElWidget *w = el_widget_get(handle);
+ if (!w) return;
+ JNIEnv *env = el_jni_env();
+ if (env && g_bridge_class) {
+ (*env)->CallStaticVoidMethod(env, g_bridge_class, g_mid_destroyView,
+ (jint)w->slot);
+ if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
+ }
+ el_widget_free(handle);
+ el_jni_detach_if_attached();
+}
+
+/* ── Manifest reader ─────────────────────────────────────────────────────── */
+/*
+ * __manifest_read: parse the app{} block from a manifest file.
+ * Returns the raw file contents as an el_val_t (const char* cast).
+ * The caller (el program) parses the returned string.
+ * Reads from the filesystem; for APK assets use the AssetManager path instead.
+ */
+static char *el_read_file(const char *path) {
+ if (!path || !*path) return NULL;
+ FILE *f = fopen(path, "rb");
+ if (!f) return NULL;
+ fseek(f, 0, SEEK_END);
+ long len = ftell(f);
+ fseek(f, 0, SEEK_SET);
+ if (len <= 0) { fclose(f); return NULL; }
+ char *buf = (char *)malloc((size_t)len + 1);
+ if (!buf) { fclose(f); return NULL; }
+ fread(buf, 1, (size_t)len, f);
+ buf[len] = '\0';
+ fclose(f);
+ return buf;
+}
+
+el_val_t el_android_manifest_read(const char *path) {
+ char *contents = el_read_file(path);
+ if (!contents) return (el_val_t)(uintptr_t)"";
+ return (el_val_t)(uintptr_t)contents; /* caller owns allocation */
+}
+
+/* ── __widget_* C API (called from el_seed.c) ────────────────────────────── */
+/*
+ * These are the functions declared in el_native_target.h under EL_TARGET_ANDROID.
+ * They forward to the el_android_* internal functions above.
+ *
+ * The el_val_t / int64_t ABI matches the AppKit functions exactly:
+ * - Integer params passed as int64_t, extracted with (int)
+ * - String params passed as int64_t, extracted with (const char*)(uintptr_t)
+ * - Float params (r,g,b,a) passed as int64_t bit-cast from double; extracted
+ * with el_to_float / bit-cast union
+ */
+
+static inline float el_val_to_float(el_val_t v) {
+ union { double d; int64_t i; } u;
+ u.i = v;
+ return (float)u.d;
+}
+
+void __native_init(void) {
+ el_android_init();
+}
+
+void __native_run_loop(void) {
+ /* No-op on Android — lifecycle is driven by the Activity. */
+}
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_android_window_create(
+ (const char *)(uintptr_t)title,
+ (int)width, (int)height, (int)min_width, (int)min_height);
+}
+
+void __window_show(el_val_t handle) {
+ el_android_window_show((int64_t)handle);
+}
+
+void __window_set_title(el_val_t handle, el_val_t title) {
+ el_android_window_set_title((int64_t)handle,
+ (const char *)(uintptr_t)title);
+}
+
+el_val_t __vstack_create(el_val_t spacing) {
+ return (el_val_t)el_android_vstack_create((int)spacing);
+}
+
+el_val_t __hstack_create(el_val_t spacing) {
+ return (el_val_t)el_android_hstack_create((int)spacing);
+}
+
+el_val_t __zstack_create(void) {
+ return (el_val_t)el_android_zstack_create();
+}
+
+el_val_t __scroll_create(void) {
+ return (el_val_t)el_android_scroll_create();
+}
+
+el_val_t __label_create(el_val_t text) {
+ return (el_val_t)el_android_label_create((const char *)(uintptr_t)text);
+}
+
+el_val_t __button_create(el_val_t label) {
+ return (el_val_t)el_android_button_create((const char *)(uintptr_t)label);
+}
+
+el_val_t __text_field_create(el_val_t placeholder) {
+ return (el_val_t)el_android_text_field_create((const char *)(uintptr_t)placeholder);
+}
+
+el_val_t __text_area_create(el_val_t placeholder) {
+ return (el_val_t)el_android_text_area_create((const char *)(uintptr_t)placeholder);
+}
+
+el_val_t __image_create(el_val_t path_or_name) {
+ return (el_val_t)el_android_image_create((const char *)(uintptr_t)path_or_name);
+}
+
+void __widget_set_text(el_val_t handle, el_val_t text) {
+ el_android_widget_set_text((int64_t)handle,
+ (const char *)(uintptr_t)text);
+}
+
+el_val_t __widget_get_text(el_val_t handle) {
+ return (el_val_t)(uintptr_t)el_android_widget_get_text((int64_t)handle);
+}
+
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a) {
+ el_android_widget_set_color((int64_t)handle,
+ el_val_to_float(r), el_val_to_float(g),
+ el_val_to_float(b), el_val_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a) {
+ el_android_widget_set_bg_color((int64_t)handle,
+ el_val_to_float(r), el_val_to_float(g),
+ el_val_to_float(b), el_val_to_float(a));
+}
+
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold) {
+ el_android_widget_set_font((int64_t)handle,
+ (const char *)(uintptr_t)family,
+ (int)size, (int)bold);
+}
+
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_android_widget_set_padding((int64_t)handle,
+ (int)top, (int)right, (int)bottom, (int)left);
+}
+
+void __widget_set_width(el_val_t handle, el_val_t width) {
+ el_android_widget_set_width((int64_t)handle, (int)width);
+}
+
+void __widget_set_height(el_val_t handle, el_val_t height) {
+ el_android_widget_set_height((int64_t)handle, (int)height);
+}
+
+void __widget_set_flex(el_val_t handle, el_val_t flex) {
+ el_android_widget_set_flex((int64_t)handle, (int)flex);
+}
+
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius) {
+ el_android_widget_set_corner_radius((int64_t)handle, (int)radius);
+}
+
+void __widget_set_disabled(el_val_t handle, el_val_t disabled) {
+ el_android_widget_set_disabled((int64_t)handle, (int)disabled);
+}
+
+void __widget_set_hidden(el_val_t handle, el_val_t hidden) {
+ el_android_widget_set_hidden((int64_t)handle, (int)hidden);
+}
+
+void __widget_add_child(el_val_t parent, el_val_t child) {
+ el_android_widget_add_child((int64_t)parent, (int64_t)child);
+}
+
+void __widget_remove_child(el_val_t parent, el_val_t child) {
+ el_android_widget_remove_child((int64_t)parent, (int64_t)child);
+}
+
+void __widget_destroy(el_val_t handle) {
+ el_android_widget_destroy((int64_t)handle);
+}
+
+void __widget_on_click(el_val_t handle, el_val_t fn_name) {
+ el_android_widget_on_click((int64_t)handle,
+ (const char *)(uintptr_t)fn_name);
+}
+
+void __widget_on_change(el_val_t handle, el_val_t fn_name) {
+ el_android_widget_on_change((int64_t)handle,
+ (const char *)(uintptr_t)fn_name);
+}
+
+void __widget_on_submit(el_val_t handle, el_val_t fn_name) {
+ el_android_widget_on_submit((int64_t)handle,
+ (const char *)(uintptr_t)fn_name);
+}
+
+el_val_t __manifest_read(el_val_t path) {
+ return el_android_manifest_read((const char *)(uintptr_t)path);
+}
+
+/* ── MainActivity JNI entry point ─────────────────────────────────────────── */
+/*
+ * Java_com_neuron_el_MainActivity_nativeMain — invoked from MainActivity.onCreate
+ * after ElBridge.init(this). Calls the el program's compiled main() which runs
+ * the boot sequence: native_init → window_from_manifest → app_build → window_show.
+ * __native_run_loop is a no-op on Android; the Activity lifecycle drives the UI.
+ */
+JNIEXPORT void JNICALL
+Java_com_neuron_el_MainActivity_nativeMain(JNIEnv *env, jobject obj) {
+ (void)env; (void)obj;
+ extern int main(int argc, char **argv);
+ char *argv[] = {"el-app", NULL};
+ main(1, argv);
+}
+
+#endif /* EL_TARGET_ANDROID */
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_native_target.h b/ui/examples/native-hello-android/app/src/main/jni/el_native_target.h
new file mode 100644
index 0000000..dc1edcb
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_native_target.h
@@ -0,0 +1,573 @@
+/*
+ * el_native_target.h — Native widget declarations for el programs targeting
+ * native desktop UI (AppKit / GTK4 / Win32).
+ *
+ * This header is designed to be included AFTER el_runtime.h without conflict:
+ * - It does NOT redefine el_to_float, el_from_float, or any el_runtime.h
+ * static inlines.
+ * - It does NOT redeclare __println, __print, or other functions whose
+ * return types differ between el_seed.h and el_runtime.h.
+ * - It adds: native widget builtins + float arithmetic helpers that the
+ * current el_runtime.h omits but elc still emits calls to.
+ *
+ * Usage:
+ * Inject via -include at compile time, OR #include it after el_runtime.h.
+ *
+ * clang -DEL_TARGET_MACOS -include el_native_target.h -c my_app.c ...
+ */
+
+#pragma once
+
+#include
+#include
+
+/* el_val_t must already be defined by el_runtime.h or el_seed.h. */
+#ifndef EL_VAL_T_DEFINED
+typedef int64_t el_val_t;
+#endif
+
+/* ── Float arithmetic helpers ───────────────────────────────────────────────
+ * elc emits calls to float_div / float_mul etc. for Float-typed expressions.
+ * These were in el_runtime.c through v1.0.0-20260501 but are missing from the
+ * current el_runtime.h. Redeclared here as static inline to avoid link deps.
+ * Only defined if not already declared (old runtimes that still have them). */
+
+#ifndef EL_FLOAT_OPS_DEFINED
+#define EL_FLOAT_OPS_DEFINED
+
+/* el_to_float / el_from_float — bit-cast between el_val_t and double.
+ * Defined as static inline in both el_runtime.h and el_seed.h; we do NOT
+ * redefine them here. We rely on one of those headers being included first. */
+
+static inline el_val_t float_div(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b;
+ ur.d = (ub.d != 0.0) ? (ua.d / ub.d) : 0.0;
+ return ur.i;
+}
+
+static inline el_val_t float_mul(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d * ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_add(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d + ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_sub(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d - ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_lt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d < ub.d);
+}
+
+static inline el_val_t float_gt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d > ub.d);
+}
+
+static inline el_val_t float_lte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d <= ub.d);
+}
+
+static inline el_val_t float_gte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d >= ub.d);
+}
+
+static inline el_val_t float_eq(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d == ub.d);
+}
+
+#endif /* EL_FLOAT_OPS_DEFINED */
+
+/* ── Native widget system (macOS AppKit) ────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_MACOS and linked with el_appkit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid. */
+
+#ifdef EL_TARGET_MACOS
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_MACOS */
+
+/* ── Native widget system (Linux GTK4) ──────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_LINUX and linked with el_gtk4.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * All functions have the same signatures as EL_TARGET_MACOS above. */
+
+#ifdef EL_TARGET_LINUX
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as EL_TARGET_MACOS */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LINUX */
+
+/* ── Native widget system (Windows Win32) ───────────────────────────────────
+ * Available when compiled with -DEL_TARGET_WIN32 and linked with el_win32.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * Link: el_win32.obj comctl32.lib user32.lib gdi32.lib */
+
+#ifdef EL_TARGET_WIN32
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_WIN32 */
+
+/* ── Native widget system (iOS UIKit) ───────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_IOS and linked with el_uikit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * iOS lifecycle note: UIApplicationMain never returns. The el program must
+ * store its UI-build logic in a void(*)(void) function pointer, assign it to
+ * el_main_entry_fn, then call __native_run_loop. ElAppDelegate invokes
+ * el_main_entry_fn inside didFinishLaunchingWithOptions.
+ * Call el_uikit_set_args(argc, argv) from main() before __native_run_loop. */
+
+#ifdef EL_TARGET_IOS
+
+/* Lifecycle entry-function hook — set before calling __native_run_loop. */
+extern void (*el_main_entry_fn)(void);
+
+/* Forward argc/argv from main() to UIApplicationMain. */
+void el_uikit_set_args(int argc, char** argv);
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_IOS */
+
+/* ── Native widget system (Android JNI) ─────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_ANDROID and linked with
+ * libelruntime.so (which includes el_android.c compiled by the NDK build).
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Java companion: ElBridge.java (package com.neuron.el) must be compiled into
+ * the APK. The Activity must call ElBridge.init(this) before any widget ops.
+ *
+ * Link flags (in Android.mk or CMakeLists.txt):
+ * -landroid -llog -ldl */
+
+#ifdef EL_TARGET_ANDROID
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void); /* no-op on Android */
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_ANDROID */
+
+/* ── Native widget system (LVGL v9 — embedded / microcontroller) ─────────────
+ * Available when compiled with -DEL_TARGET_LVGL and linked with el_lvgl.c
+ * and the LVGL library (lvgl.a or lvgl source tree).
+ *
+ * Target platforms: ESP32, STM32, industrial panels. Any system with 256KB+
+ * RAM and an LVGL-compatible display driver. No OS required.
+ *
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Bare-metal / no dynamic linker:
+ * Compile with -DEL_LVGL_NO_DLSYM and provide:
+ * el_val_t el_lvgl_dispatch(const char *fn, el_val_t a, el_val_t b);
+ *
+ * Compile:
+ * gcc -DEL_TARGET_LVGL -I./lvgl el_lvgl.c -c -o el_lvgl.o
+ * # Then link with lvgl.a. */
+
+#ifdef EL_TARGET_LVGL
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as all other native targets */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LVGL */
+
+/* ── Native widget system (SDL2 — embedded / Pi) ────────────────────────────
+ * Available when compiled with -DEL_TARGET_SDL2 and linked with el_sdl2.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Target: Raspberry Pi Zero, embedded Linux, any system with a framebuffer
+ * and SDL2 available. No GTK, no desktop environment required.
+ *
+ * Compile:
+ * gcc -DEL_TARGET_SDL2 $(sdl2-config --cflags) -c el_sdl2.c -o el_sdl2.o
+ * Link:
+ * $(sdl2-config --libs) -lSDL2_ttf -lSDL2_image -ldl */
+
+#ifdef EL_TARGET_SDL2
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_SDL2 */
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_native_vessel.c b/ui/examples/native-hello-android/app/src/main/jni/el_native_vessel.c
new file mode 100644
index 0000000..0682965
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_native_vessel.c
@@ -0,0 +1,459 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t native_init(void);
+el_val_t native_run_loop(void);
+el_val_t manifest_read(el_val_t path);
+el_val_t manifest_title(el_val_t m);
+el_val_t manifest_width(el_val_t m);
+el_val_t manifest_height(el_val_t m);
+el_val_t manifest_min_width(el_val_t m);
+el_val_t manifest_min_height(el_val_t m);
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height);
+el_val_t window_from_manifest(el_val_t manifest_path);
+el_val_t window_show(el_val_t handle);
+el_val_t window_set_title(el_val_t handle, el_val_t title);
+el_val_t vstack(el_val_t spacing);
+el_val_t vstack_tight(void);
+el_val_t hstack(el_val_t spacing);
+el_val_t zstack(void);
+el_val_t scroll(void);
+el_val_t label(el_val_t text);
+el_val_t button(el_val_t label);
+el_val_t text_field(el_val_t placeholder);
+el_val_t text_area(el_val_t placeholder);
+el_val_t image(el_val_t path_or_name);
+el_val_t widget_set_text(el_val_t handle, el_val_t text);
+el_val_t widget_get_text(el_val_t handle);
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold);
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left);
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p);
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py);
+el_val_t widget_set_width(el_val_t handle, el_val_t width);
+el_val_t widget_set_height(el_val_t handle, el_val_t height);
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex);
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius);
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled);
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden);
+el_val_t widget_add_child(el_val_t parent, el_val_t child);
+el_val_t widget_remove_child(el_val_t parent, el_val_t child);
+el_val_t widget_destroy(el_val_t handle);
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name);
+el_val_t hex_channel(el_val_t s, el_val_t offset);
+el_val_t hex_nibble(el_val_t c);
+el_val_t color_hex_r(el_val_t hex);
+el_val_t color_hex_g(el_val_t hex);
+el_val_t color_hex_b(el_val_t hex);
+el_val_t color_hex_a(el_val_t hex);
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex);
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex);
+el_val_t style_surface(el_val_t handle);
+el_val_t style_button_primary(el_val_t handle);
+el_val_t style_label_body(el_val_t handle);
+el_val_t style_label_heading(el_val_t handle);
+el_val_t style_label_muted(el_val_t handle);
+
+el_val_t TOKEN_PRIMARY;
+el_val_t TOKEN_ON_PRIMARY;
+el_val_t TOKEN_BACKGROUND;
+el_val_t TOKEN_ON_BG;
+el_val_t TOKEN_SURFACE;
+el_val_t TOKEN_ON_SURFACE;
+el_val_t TOKEN_OUTLINE;
+el_val_t TOKEN_ERROR;
+
+el_val_t native_init(void) {
+ __native_init();
+ return 0;
+}
+
+el_val_t native_run_loop(void) {
+ __native_run_loop();
+ return 0;
+}
+
+el_val_t manifest_read(el_val_t path) {
+ return __manifest_read(path);
+ return 0;
+}
+
+el_val_t manifest_title(el_val_t m) {
+ return json_get_string(m, EL_STR("title"));
+ return 0;
+}
+
+el_val_t manifest_width(el_val_t m) {
+ return json_get_int(m, EL_STR("width"));
+ return 0;
+}
+
+el_val_t manifest_height(el_val_t m) {
+ return json_get_int(m, EL_STR("height"));
+ return 0;
+}
+
+el_val_t manifest_min_width(el_val_t m) {
+ return json_get_int(m, EL_STR("min_width"));
+ return 0;
+}
+
+el_val_t manifest_min_height(el_val_t m) {
+ return json_get_int(m, EL_STR("min_height"));
+ return 0;
+}
+
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height) {
+ return __window_create(title, width, height, min_width, min_height);
+ return 0;
+}
+
+el_val_t window_from_manifest(el_val_t manifest_path) {
+ el_val_t m = manifest_read(manifest_path);
+ el_val_t title = manifest_title(m);
+ el_val_t w = manifest_width(m);
+ el_val_t h = manifest_height(m);
+ el_val_t mw = manifest_min_width(m);
+ el_val_t mh = manifest_min_height(m);
+ el_val_t safe_w = ({ el_val_t _if_result_1 = 0; if ((w > 0)) { _if_result_1 = (w); } else { _if_result_1 = (1200); } _if_result_1; });
+ el_val_t safe_h = ({ el_val_t _if_result_2 = 0; if ((h > 0)) { _if_result_2 = (h); } else { _if_result_2 = (800); } _if_result_2; });
+ el_val_t safe_mw = ({ el_val_t _if_result_3 = 0; if ((mw > 0)) { _if_result_3 = (mw); } else { _if_result_3 = (600); } _if_result_3; });
+ el_val_t safe_mh = ({ el_val_t _if_result_4 = 0; if ((mh > 0)) { _if_result_4 = (mh); } else { _if_result_4 = (400); } _if_result_4; });
+ el_val_t safe_t = ({ el_val_t _if_result_5 = 0; if ((str_len(title) > 0)) { _if_result_5 = (title); } else { _if_result_5 = (EL_STR("App")); } _if_result_5; });
+ return window_create(safe_t, safe_w, safe_h, safe_mw, safe_mh);
+ return 0;
+}
+
+el_val_t window_show(el_val_t handle) {
+ __window_show(handle);
+ return 0;
+}
+
+el_val_t window_set_title(el_val_t handle, el_val_t title) {
+ __window_set_title(handle, title);
+ return 0;
+}
+
+el_val_t vstack(el_val_t spacing) {
+ return __vstack_create(spacing);
+ return 0;
+}
+
+el_val_t vstack_tight(void) {
+ return __vstack_create(0);
+ return 0;
+}
+
+el_val_t hstack(el_val_t spacing) {
+ return __hstack_create(spacing);
+ return 0;
+}
+
+el_val_t zstack(void) {
+ return __zstack_create();
+ return 0;
+}
+
+el_val_t scroll(void) {
+ return __scroll_create();
+ return 0;
+}
+
+el_val_t label(el_val_t text) {
+ return __label_create(text);
+ return 0;
+}
+
+el_val_t button(el_val_t label) {
+ return __button_create(label);
+ return 0;
+}
+
+el_val_t text_field(el_val_t placeholder) {
+ return __text_field_create(placeholder);
+ return 0;
+}
+
+el_val_t text_area(el_val_t placeholder) {
+ return __text_area_create(placeholder);
+ return 0;
+}
+
+el_val_t image(el_val_t path_or_name) {
+ return __image_create(path_or_name);
+ return 0;
+}
+
+el_val_t widget_set_text(el_val_t handle, el_val_t text) {
+ __widget_set_text(handle, text);
+ return 0;
+}
+
+el_val_t widget_get_text(el_val_t handle) {
+ return __widget_get_text(handle);
+ return 0;
+}
+
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_bg_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold) {
+ el_val_t bold_int = ({ el_val_t _if_result_6 = 0; if (bold) { _if_result_6 = (1); } else { _if_result_6 = (0); } _if_result_6; });
+ __widget_set_font(handle, family, size, bold_int);
+ return 0;
+}
+
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left) {
+ __widget_set_padding(handle, top, right, bottom, left);
+ return 0;
+}
+
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p) {
+ __widget_set_padding(handle, p, p, p, p);
+ return 0;
+}
+
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py) {
+ __widget_set_padding(handle, py, px, py, px);
+ return 0;
+}
+
+el_val_t widget_set_width(el_val_t handle, el_val_t width) {
+ __widget_set_width(handle, width);
+ return 0;
+}
+
+el_val_t widget_set_height(el_val_t handle, el_val_t height) {
+ __widget_set_height(handle, height);
+ return 0;
+}
+
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex) {
+ __widget_set_flex(handle, flex);
+ return 0;
+}
+
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius) {
+ __widget_set_corner_radius(handle, radius);
+ return 0;
+}
+
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled) {
+ el_val_t d = ({ el_val_t _if_result_7 = 0; if (disabled) { _if_result_7 = (1); } else { _if_result_7 = (0); } _if_result_7; });
+ __widget_set_disabled(handle, d);
+ return 0;
+}
+
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden) {
+ el_val_t h = ({ el_val_t _if_result_8 = 0; if (hidden) { _if_result_8 = (1); } else { _if_result_8 = (0); } _if_result_8; });
+ __widget_set_hidden(handle, h);
+ return 0;
+}
+
+el_val_t widget_add_child(el_val_t parent, el_val_t child) {
+ __widget_add_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_remove_child(el_val_t parent, el_val_t child) {
+ __widget_remove_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_destroy(el_val_t handle) {
+ __widget_destroy(handle);
+ return 0;
+}
+
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name) {
+ __widget_on_click(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name) {
+ __widget_on_change(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name) {
+ __widget_on_submit(handle, fn_name);
+ return 0;
+}
+
+el_val_t hex_channel(el_val_t s, el_val_t offset) {
+ el_val_t hi = str_slice(s, offset, (offset + 1));
+ el_val_t lo = str_slice(s, (offset + 1), (offset + 2));
+ el_val_t h = hex_nibble(hi);
+ el_val_t l = hex_nibble(lo);
+ return ((h * 16) + l);
+ return 0;
+}
+
+el_val_t hex_nibble(el_val_t c) {
+ if (str_eq(c, EL_STR("0"))) {
+ return 0;
+ }
+ if (str_eq(c, EL_STR("1"))) {
+ return 1;
+ }
+ if (str_eq(c, EL_STR("2"))) {
+ return 2;
+ }
+ if (str_eq(c, EL_STR("3"))) {
+ return 3;
+ }
+ if (str_eq(c, EL_STR("4"))) {
+ return 4;
+ }
+ if (str_eq(c, EL_STR("5"))) {
+ return 5;
+ }
+ if (str_eq(c, EL_STR("6"))) {
+ return 6;
+ }
+ if (str_eq(c, EL_STR("7"))) {
+ return 7;
+ }
+ if (str_eq(c, EL_STR("8"))) {
+ return 8;
+ }
+ if (str_eq(c, EL_STR("9"))) {
+ return 9;
+ }
+ if (str_eq(c, EL_STR("a"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("b"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("c"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("d"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("e"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("f"))) {
+ return 15;
+ }
+ if (str_eq(c, EL_STR("A"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("B"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("C"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("D"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("E"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("F"))) {
+ return 15;
+ }
+ return 0;
+ return 0;
+}
+
+el_val_t color_hex_r(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_9 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_9 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_9 = (hex); } _if_result_9; });
+ el_val_t v = hex_channel(s, 0);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_g(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_10 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_10 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_10 = (hex); } _if_result_10; });
+ el_val_t v = hex_channel(s, 2);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_b(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_11 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_11 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_11 = (hex); } _if_result_11; });
+ el_val_t v = hex_channel(s, 4);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_a(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_12 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_12 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_12 = (hex); } _if_result_12; });
+ if (str_len(s) < 8) {
+ return el_from_float(1.0);
+ }
+ el_val_t v = hex_channel(s, 6);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_bg_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t style_surface(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_SURFACE);
+ widget_set_corner_radius(handle, 8);
+ return 0;
+}
+
+el_val_t style_button_primary(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_PRIMARY);
+ widget_set_color_hex(handle, TOKEN_ON_PRIMARY);
+ widget_set_corner_radius(handle, 8);
+ widget_set_padding_xy(handle, 16, 8);
+ return 0;
+}
+
+el_val_t style_label_body(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 14, 0);
+ return 0;
+}
+
+el_val_t style_label_heading(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 20, 1);
+ return 0;
+}
+
+el_val_t style_label_muted(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_OUTLINE);
+ widget_set_font(handle, EL_STR("system"), 12, 0);
+ return 0;
+}
+
+int el_vessel_main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ TOKEN_PRIMARY = EL_STR("#60a5fa");
+ TOKEN_ON_PRIMARY = EL_STR("#0f172a");
+ TOKEN_BACKGROUND = EL_STR("#0f172a");
+ TOKEN_ON_BG = EL_STR("#f8fafc");
+ TOKEN_SURFACE = EL_STR("#1e293b");
+ TOKEN_ON_SURFACE = EL_STR("#f8fafc");
+ TOKEN_OUTLINE = EL_STR("#475569");
+ TOKEN_ERROR = EL_STR("#f87171");
+ return 0;
+}
+
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_runtime.c b/ui/examples/native-hello-android/app/src/main/jni/el_runtime.c
new file mode 100644
index 0000000..a735c9f
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_runtime.c
@@ -0,0 +1,11733 @@
+/*
+ * 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 -lcurl -lpthread -o .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
+#include /* strcasecmp */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include /* dlsym for http_set_handler fallback */
+#include
+#include
+#include
+#include
+#include
+#include /* getrusage — memory guard */
+#ifdef HAVE_CURL
+#include
+#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;
+
+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, "= 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), and *out_body (allocated). On failure returns 0.
+ *
+ * Implementation: feeds the entire envelope through the recursive-descent
+ * JSON parser (which builds proper ElMap/ElList values), then pulls the
+ * three top-level fields by name. Avoids re-stringifying the headers map
+ * since json_stringify() does not support nested objects. */
+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;
+
+ el_val_t parsed = json_parse(EL_STR(s));
+ if (parsed == EL_NULL) return 0;
+
+ int status = 200;
+ el_val_t hmap = 0;
+ char* body = NULL;
+
+ el_val_t sv = el_map_get(parsed, EL_STR("status"));
+ if (sv != 0) {
+ /* status comes back as an integer — el_val_t holds it directly. */
+ long sc = (long)sv;
+ if (sc >= 100 && sc <= 599) status = (int)sc;
+ }
+
+ el_val_t hv = el_map_get(parsed, EL_STR("headers"));
+ if (hv != 0) {
+ ElMap* hm = (ElMap*)(uintptr_t)hv;
+ if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
+ }
+
+ el_val_t bv = el_map_get(parsed, EL_STR("body"));
+ if (bv != 0) {
+ const char* bs = EL_CSTR(bv);
+ if (bs) body = el_strdup(bs);
+ }
+ if (!body) body = el_strdup("");
+
+ *out_status = status;
+ *out_headers_map = hmap;
+ *out_body = body;
+ *out_parsed_root = parsed; /* caller releases to free hmap + entries */
+ 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__":,...}` 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;
+ /* 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 = (_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);
+}
+
+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 = "";
+
+ 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\":");
+ 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
+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 (") */
+ 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, "&", 5); p += 5; break;
+ case '<': memcpy(p, "<", 4); p += 4; break;
+ case '>': memcpy(p, ">", 4); p += 4; break;
+ case '"': memcpy(p, """, 6); p += 6; break;
+ case '\'': memcpy(p, "'", 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 `` is
+ * CDATA-like and must not be re-emitted as escaped text either.
+ * - Comments (), doctype (), 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 `` 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 (<, &,
+ * 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, "<"); break;
+ case '>': html_buf_puts(out, ">"); break;
+ case '"': html_buf_puts(out, """); break;
+ case '\'': html_buf_puts(out, "'"); 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 (`<`, `&`, `...;`)
+ * 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, "<");
+ 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, "<");
+ 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, "<");
+ 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 ``. */
+ 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, """);
+ else if (y == '<') html_buf_puts(&out, "<");
+ else if (y == '>') html_buf_puts(&out, ">");
+ 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_ 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 (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;
+ }
+
+ /* ── TRAVERSAL INFERENCE: infer A→C edges when A→B→C was traversed ──
+ * For each pair of edges (A→B, B→C) where all three nodes were reached,
+ * create an inferred A→C edge with weight = w(A→B) * w(B→C) * 0.8
+ * if no A→C edge already exists. Cap at 64 new edges per call.
+ *
+ * IMPORTANT: collect candidates FIRST into a flat array (no pointers into
+ * g->edges held across the apply pass), then apply after — this avoids
+ * dangling pointer bugs if engram_grow_edges() reallocs the array. */
+ {
+ const int64_t INFER_CAP = 64;
+ typedef struct { char from[64]; char to[64]; double weight; } InferCandidate;
+ InferCandidate* cands = malloc((size_t)INFER_CAP * sizeof(InferCandidate));
+ int64_t ncands = 0;
+ int64_t snap_ec = g->edge_count;
+ if (cands) {
+ for (int64_t e1 = 0; e1 < snap_ec && ncands < INFER_CAP; e1++) {
+ EngramEdge* ea = &g->edges[e1];
+ if (!ea->from_id || !ea->to_id) continue;
+ int64_t ai = engram_find_node_index(ea->from_id);
+ int64_t bi = engram_find_node_index(ea->to_id);
+ if (ai < 0 || bi < 0) continue;
+ if (!reached[ai] || !reached[bi]) continue;
+ for (int64_t e2 = 0; e2 < snap_ec && ncands < INFER_CAP; e2++) {
+ if (e2 == e1) continue;
+ EngramEdge* eb = &g->edges[e2];
+ if (!eb->from_id || !eb->to_id) continue;
+ if (strcmp(eb->from_id, ea->to_id) != 0) continue;
+ int64_t ci = engram_find_node_index(eb->to_id);
+ if (ci < 0 || !reached[ci]) continue;
+ if (ai == ci) continue;
+ int already = 0;
+ for (int64_t ex = 0; ex < snap_ec; ex++) {
+ EngramEdge* ee = &g->edges[ex];
+ if (ee->from_id && ee->to_id &&
+ strcmp(ee->from_id, ea->from_id) == 0 &&
+ strcmp(ee->to_id, eb->to_id) == 0) {
+ already = 1; break;
+ }
+ }
+ if (already) continue;
+ int dup = 0;
+ for (int64_t k = 0; k < ncands; k++) {
+ if (strcmp(cands[k].from, ea->from_id) == 0 &&
+ strcmp(cands[k].to, eb->to_id) == 0) { dup = 1; break; }
+ }
+ if (dup) continue;
+ double inf_w = ea->weight * eb->weight * 0.8;
+ if (inf_w < 0.05) inf_w = 0.05;
+ if (inf_w > 1.0) inf_w = 1.0;
+ strncpy(cands[ncands].from, ea->from_id, 63); cands[ncands].from[63] = '\0';
+ strncpy(cands[ncands].to, eb->to_id, 63); cands[ncands].to[63] = '\0';
+ cands[ncands].weight = inf_w;
+ ncands++;
+ }
+ }
+ for (int64_t k = 0; k < ncands; k++) {
+ engram_grow_edges();
+ EngramEdge* ne = &g->edges[g->edge_count];
+ memset(ne, 0, sizeof(*ne));
+ ne->id = engram_new_id();
+ /* Use strdup (not el_strdup) so these persist beyond the request. */
+ ne->from_id = strdup(cands[k].from);
+ ne->to_id = strdup(cands[k].to);
+ ne->relation = strdup("inferred");
+ ne->metadata = strdup("{}");
+ ne->weight = cands[k].weight;
+ ne->confidence = 0.8;
+ ne->created_at = now_ms;
+ ne->updated_at = now_ms;
+ ne->last_fired = now_ms;
+ ne->layer_id = ENGRAM_LAYER_DEFAULT;
+ g->edge_count++;
+ }
+ free(cands);
+ }
+ }
+
+ /* ── 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];
+ }
+
+ /* ── HEBBIAN STRENGTHENING: fire together, wire together ─────────────
+ * For each pair of co-promoted nodes (working_memory_weight > 0) that
+ * share an edge, boost that edge's weight by 0.05 (capped at 1.0).
+ * Also increment activation_count and update last_activated on promoted
+ * nodes — this is what drives tier migration below. */
+ for (int64_t i = 0; i < g->node_count; i++) {
+ if (wm_weights[i] <= 0.0) continue;
+ EngramNode* n = &g->nodes[i];
+ n->activation_count++;
+ n->last_activated = now_ms;
+ n->updated_at = now_ms;
+ }
+ for (int64_t ei = 0; ei < g->edge_count; ei++) {
+ EngramEdge* e = &g->edges[ei];
+ if (!e->from_id || !e->to_id) 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 (wm_weights[src] > 0.0 && wm_weights[tgt] > 0.0) {
+ e->weight += 0.05;
+ if (e->weight > 1.0) e->weight = 1.0;
+ e->last_fired = now_ms;
+ e->updated_at = now_ms;
+ }
+ }
+
+ /* ── TIER MIGRATION: promote nodes based on activation_count thresholds ─
+ * 0–4 → Working
+ * 5–19 → Episodic
+ * 20–49 → Semantic
+ * 50+ → Procedural
+ * Only upgrade (never downgrade) to preserve earned tier. */
+ for (int64_t i = 0; i < g->node_count; i++) {
+ EngramNode* n = &g->nodes[i];
+ const char* target_tier = NULL;
+ int64_t ac = n->activation_count;
+ if (ac >= 50) target_tier = "Procedural";
+ else if (ac >= 20) target_tier = "Semantic";
+ else if (ac >= 5) target_tier = "Episodic";
+ else target_tier = "Working";
+ if (target_tier && n->tier && strcmp(n->tier, target_tier) != 0) {
+ /* Only upgrade (Working < Episodic < Semantic < Procedural). */
+ int cur_rank = 0, new_rank = 0;
+ if (strcmp(n->tier, "Working") == 0) cur_rank = 0;
+ else if (strcmp(n->tier, "Episodic") == 0) cur_rank = 1;
+ else if (strcmp(n->tier, "Semantic") == 0) cur_rank = 2;
+ else if (strcmp(n->tier, "Procedural") == 0) cur_rank = 3;
+ if (strcmp(target_tier, "Working") == 0) new_rank = 0;
+ else if (strcmp(target_tier, "Episodic") == 0) new_rank = 1;
+ else if (strcmp(target_tier, "Semantic") == 0) new_rank = 2;
+ else if (strcmp(target_tier, "Procedural") == 0) new_rank = 3;
+ if (new_rank > cur_rank) {
+ free(n->tier);
+ /* Use strdup (not el_strdup) so tier string persists beyond the request. */
+ n->tier = strdup(target_tier);
+ n->updated_at = now_ms;
+ }
+ }
+ }
+
+ /* ── 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);
+}
+
+/* ── engram_apply_decay_json — temporal decay maintenance ────────────────────
+ *
+ * Iterates ALL nodes and applies temporal decay to their stored `salience`
+ * field based on time elapsed since `last_activated`:
+ *
+ * new_salience = current_salience * decay_rate ^ hours_since_activation
+ *
+ * where decay_rate defaults to 0.5^(1/168) per hour (half-life one week),
+ * or the node's own `temporal_decay_rate` if non-zero.
+ *
+ * Nodes with temporal_decay_rate == 0 are NOT immune — the global default
+ * applies. To make a node truly immune, set temporal_decay_rate to a very
+ * small positive value (e.g. 0.0001). Nodes that are "pinned" can be
+ * identified by a tier of "Procedural" — those are skipped.
+ *
+ * After updating salience, nodes with salience < 0.05 AND tier == "Working"
+ * are pruned (deleted) unless they have no content (guard against garbage).
+ *
+ * Returns a JSON summary: {"updated": N, "pruned": N} */
+el_val_t engram_apply_decay_json(void) {
+ EngramStore* g = engram_get();
+ int64_t now_ms = engram_now_ms();
+ int64_t updated = 0, pruned = 0;
+
+ for (int64_t i = 0; i < g->node_count; i++) {
+ EngramNode* n = &g->nodes[i];
+ /* Skip Procedural nodes (they are "locked in"). */
+ if (n->tier && strcmp(n->tier, "Procedural") == 0) continue;
+ int64_t age_ms = now_ms - n->last_activated;
+ if (age_ms <= 0) continue;
+ double lambda = (n->temporal_decay_rate > 0.0) ? n->temporal_decay_rate
+ : ENGRAM_DECAY_LAMBDA;
+ double age_hours = (double)age_ms / 3600000.0;
+ double decay_factor = exp(-lambda * age_hours / ENGRAM_T_HALF_HOURS);
+ double new_salience = n->salience * decay_factor;
+ if (new_salience < 0.0) new_salience = 0.0;
+ if (new_salience != n->salience) {
+ n->salience = new_salience;
+ n->updated_at = now_ms;
+ updated++;
+ }
+ }
+
+ /* Prune low-salience Working nodes. Walk backwards to allow in-place
+ * removal without invalidating indices. */
+ for (int64_t i = g->node_count - 1; i >= 0; i--) {
+ EngramNode* n = &g->nodes[i];
+ if (n->salience >= 0.05) continue;
+ /* Only prune Working tier nodes — higher tiers are protected. */
+ if (!n->tier || strcmp(n->tier, "Working") != 0) continue;
+ /* Guard: skip nodes with no content. */
+ if (!n->content || !*n->content) continue;
+ /* Free node strings. */
+ 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 j = i + 1; j < g->node_count; j++) {
+ g->nodes[j - 1] = g->nodes[j];
+ }
+ g->node_count--;
+ memset(&g->nodes[g->node_count], 0, sizeof(EngramNode));
+ pruned++;
+ }
+
+ /* Remove dangling edges for pruned nodes (any edge whose endpoint no
+ * longer exists in the node list). */
+ if (pruned > 0) {
+ int64_t w = 0;
+ for (int64_t r = 0; r < g->edge_count; r++) {
+ EngramEdge* e = &g->edges[r];
+ int dangling = 0;
+ if (e->from_id && engram_find_node_index(e->from_id) < 0) dangling = 1;
+ if (e->to_id && engram_find_node_index(e->to_id) < 0) dangling = 1;
+ if (dangling) {
+ 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;
+ }
+
+ char buf[128];
+ snprintf(buf, sizeof(buf),
+ "{\"ok\":true,\"updated\":%lld,\"pruned\":%lld}",
+ (long long)updated, (long long)pruned);
+ return el_wrap_str(el_strdup(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
+ * "@" where is the peer's Engram-exposed daemon.
+ *
+ * Channels are logical handles cached per-cgi: `dharma_connect` is
+ * idempotent and returns "ch:". 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:"). 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 @ suffix */
+ char* base_id; /* registry-id portion (before @) for relationship lookup */
+ char* url; /* resolved transport URL */
+ char* channel_id; /* "ch:" */
+} 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 "@" → (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:". 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 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()
+#include
+#define EL_HAVE_LIBOQS 1
+#else
+#define EL_HAVE_LIBOQS 0
+#endif
+
+#if EL_HAVE_LIBOQS && __has_include()
+#include
+#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()
+
+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
+#include
+
+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() */
+
+#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 */
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_runtime.h b/ui/examples/native-hello-android/app/src/main/jni/el_runtime.h
new file mode 100644
index 0000000..2f9583f
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_runtime.h
@@ -0,0 +1,883 @@
+/*
+ * el_runtime.h — El language C runtime header
+ *
+ * Declares all built-in functions available to compiled El programs.
+ * Include this in every generated .c file.
+ *
+ * Value model:
+ * All El values are represented as el_val_t (= int64_t).
+ * On 64-bit systems a pointer fits in int64_t.
+ * String values are cast: (el_val_t)(uintptr_t)"hello"
+ * Integer values are stored directly.
+ * This lets arithmetic work naturally while still passing strings around.
+ *
+ * Type conventions (El -> C):
+ * String -> el_val_t (holds const char* via uintptr_t cast)
+ * Int -> el_val_t
+ * Bool -> el_val_t (0 = false, nonzero = true)
+ * Any -> el_val_t
+ * Void -> void
+ *
+ * Macros for convenience:
+ * EL_STR(s) cast string literal to el_val_t
+ * EL_CSTR(v) cast el_val_t back to const char*
+ * EL_INT(v) identity — el_val_t is already int64_t
+ * EL_NULL null / zero value
+ * EL_FALSE boolean false (0)
+ * EL_TRUE boolean true (1)
+ *
+ * Link requirements:
+ * -lcurl — required for the HTTP client (http_get, http_post, llm_*).
+ * -lpthread — required for the HTTP server (one detached thread per
+ * connection, capped at 64 concurrent).
+ * -loqs — optional; required only when liboqs is installed and the
+ * pq_* / sha3_256_hex entry points are needed. Detected at
+ * compile time via __has_include().
+ * -lcrypto — optional; pulled in alongside -loqs. Used for X25519 in
+ * pq_hybrid_* and HKDF-SHA256 derivation.
+ *
+ * Canonical compile command:
+ * cc -std=c11 -I el-compiler/runtime -lcurl -lpthread \
+ * -o .c el-compiler/runtime/el_runtime.c
+ *
+ * With liboqs (post-quantum stack):
+ * cc -std=c11 -I el-compiler/runtime -lcurl -lpthread -loqs -lcrypto \
+ * -o .c el-compiler/runtime/el_runtime.c
+ */
+
+#pragma once
+
+#include
+#include
+
+typedef int64_t el_val_t;
+
+#define EL_STR(s) ((el_val_t)(uintptr_t)(s))
+#define EL_CSTR(v) ((const char*)(uintptr_t)(v))
+#define EL_INT(v) (v)
+#define EL_NULL ((el_val_t)0)
+#define EL_FALSE ((el_val_t)0)
+#define EL_TRUE ((el_val_t)1)
+
+/* Float values share the el_val_t (int64) slot via a bit-cast.
+ * The codegen emits Float literals as `el_from_float()` so the
+ * underlying bits represent the IEEE 754 double. Float-aware builtins
+ * (math, format, json) round-trip via these helpers. */
+static inline double el_to_float(el_val_t v) {
+ union { int64_t i; double f; } u;
+ u.i = (int64_t)v;
+ return u.f;
+}
+
+static inline el_val_t el_from_float(double f) {
+ union { double f; int64_t i; } u;
+ u.f = f;
+ return (el_val_t)u.i;
+}
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ── I/O ──────────────────────────────────────────────────────────────────── */
+
+el_val_t println(el_val_t s);
+el_val_t print(el_val_t s);
+el_val_t readline(void);
+
+/* ── String builtins ─────────────────────────────────────────────────────── */
+
+el_val_t el_str_concat(el_val_t a, el_val_t b);
+el_val_t str_eq(el_val_t a, el_val_t b);
+el_val_t str_starts_with(el_val_t s, el_val_t prefix);
+el_val_t str_ends_with(el_val_t s, el_val_t suffix);
+el_val_t str_len(el_val_t s);
+el_val_t str_concat(el_val_t a, el_val_t b);
+el_val_t int_to_str(el_val_t n);
+el_val_t str_to_int(el_val_t s);
+el_val_t native_str_to_int(el_val_t s);
+el_val_t str_slice(el_val_t s, el_val_t start, el_val_t end);
+el_val_t str_contains(el_val_t s, el_val_t sub);
+el_val_t str_replace(el_val_t s, el_val_t from, el_val_t to);
+el_val_t str_to_upper(el_val_t s);
+el_val_t str_to_lower(el_val_t s);
+el_val_t str_trim(el_val_t s);
+
+/* ── Math ────────────────────────────────────────────────────────────────── */
+
+el_val_t el_abs(el_val_t n);
+el_val_t el_max(el_val_t a, el_val_t b);
+el_val_t el_min(el_val_t a, el_val_t b);
+
+/* ── Refcount (ARC) ──────────────────────────────────────────────────────────
+ * Lists and Maps carry a refcount. Strings and ints do not — el_retain and
+ * el_release are safe no-ops on non-refcounted values (they sniff a magic
+ * header at offset 0 and only act if the magic matches).
+ *
+ * Codegen emits these at let-binding shadowing, function entry (params), and
+ * function exit (locals other than the returned value). The refcount lets
+ * el_list_append and el_map_set mutate in place when uniquely owned (cheap)
+ * and copy-on-write when shared (preserves persistent semantics across
+ * accumulator patterns in the compiler itself). */
+
+void el_retain(el_val_t v);
+void el_release(el_val_t v);
+
+/* ── Scoped arena (CLI use) ───────────────────────────────────────────────── */
+el_val_t el_arena_push(void);
+el_val_t el_arena_pop(el_val_t mark);
+
+/* ── List ────────────────────────────────────────────────────────────────── */
+
+el_val_t el_list_new(el_val_t count, ...);
+el_val_t el_list_len(el_val_t list);
+el_val_t el_list_get(el_val_t list, el_val_t index);
+el_val_t el_list_append(el_val_t list, el_val_t elem);
+el_val_t el_list_empty(void);
+el_val_t el_list_clone(el_val_t list);
+
+/* ── Map ─────────────────────────────────────────────────────────────────── */
+
+el_val_t el_map_new(el_val_t pair_count, ...);
+el_val_t el_get_field(el_val_t map, el_val_t key);
+el_val_t el_map_get(el_val_t map, el_val_t key);
+el_val_t el_map_set(el_val_t map, el_val_t key, el_val_t value);
+
+/* ── HTTP ─────────────────────────────────────────────────────────────────── */
+
+el_val_t http_get(el_val_t url);
+el_val_t http_post(el_val_t url, el_val_t body);
+el_val_t http_post_json(el_val_t url, el_val_t json_body);
+el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map);
+el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map);
+el_val_t http_post_json_with_headers(el_val_t url, el_val_t headers_map, el_val_t json_body);
+el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header);
+el_val_t http_delete(el_val_t url);
+el_val_t http_serve(el_val_t port, el_val_t handler);
+el_val_t http_set_handler(el_val_t name);
+
+/* HTTP server v2 ─────────────────────────────────────────────────────────────
+ * Same dispatch model as http_serve, but the handler signature is widened:
+ *
+ * el_val_t handler(method, path, headers_map, body)
+ *
+ * `headers_map` is an ElMap from lowercased header name → header value (both
+ * Strings). Repeated headers are joined with ", " per RFC 7230.
+ *
+ * Response value: the handler may return either
+ * (a) a plain body string — same auto-content-type / 200-OK behaviour as
+ * http_serve (3-arg) — or
+ * (b) a response envelope built with `http_response(status, headers_json,
+ * body)`. The runtime detects the envelope discriminator
+ * `"el_http_response":1` at the start of the returned string and
+ * unpacks status / headers / body before sending.
+ *
+ * The 3-arg http_serve(port, handler) remains supported unchanged for
+ * existing handlers (e.g. products/web/server.el): it dispatches with
+ * (method, path, body), hardcodes 200 OK, and auto-detects content type. */
+el_val_t http_serve_v2(el_val_t port, el_val_t handler);
+el_val_t http_set_handler_v2(el_val_t name);
+
+/* Build an HTTP response envelope. `headers_json` should be a JSON object
+ * literal like `{"WWW-Authenticate":"Basic"}` (or "" / "{}" for none). The
+ * returned string carries the discriminator `{"el_http_response":1,...}`
+ * which the runtime's send-path detects and unpacks. Detection happens
+ * uniformly inside http_send_response, so a 3-arg handler may also return
+ * an envelope. The 3-arg variant remains documented as a fixed 200-OK
+ * auto-content-type contract for legacy handlers that return plain bodies. */
+el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+
+/* SSE connection fd — set by http_worker_v2 before calling the El handler,
+ * cleared afterwards. Defined in el_seed.c; called from el_runtime.c.
+ * The getter is exposed as __http_conn_fd() to El programs. */
+void el_seed_set_http_conn_fd(int fd);
+
+/* HTTP timeout — every libcurl request honors EL_HTTP_TIMEOUT_MS (default
+ * 60000ms). Read lazily on first use, so setting the env var any time before
+ * the first http_* call is sufficient. */
+
+/* Streaming variants — write the response body straight to a file via
+ * libcurl's CURLOPT_WRITEFUNCTION = fwrite. These bypass the el_val_t string
+ * wrapper entirely, so binary payloads (audio/mpeg, image/png, etc.) survive
+ * embedded NUL bytes that would truncate a strlen()-based code path.
+ *
+ * Both honor EL_HTTP_TIMEOUT_MS, follow redirects, and accept the same
+ * `headers_map` shape as http_post_with_headers (ElMap of String→String).
+ *
+ * Return value: 1 on success (file fully written), 0 on any failure
+ * (network, file open, partial write). On failure the output file is removed
+ * so callers cannot mistake a partially-written file for a valid one. */
+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);
+el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_path);
+
+/* ── URL encoding ────────────────────────────────────────────────────────── */
+
+el_val_t url_encode(el_val_t s); /* RFC 3986 unreserved set */
+el_val_t url_decode(el_val_t s); /* '+' → space, %XX → byte */
+
+/* ── HTML allowlist sanitizer ────────────────────────────────────────────────
+ * el_html_sanitize(input_html, allowlist_json) — strict allowlist HTML
+ * cleaner. State-machine parser; tag/attribute names compared case-
+ * insensitively against the allowlist; `` / `<… src>` URL schemes
+ * validated (http, https, mailto, fragment-only, or relative); whole-
+ * subtree drop for script / style / iframe / object / embed / form; HTML-
+ * escapes free text outside dropped subtrees.
+ *
+ * The allowlist is JSON of the form
+ * {"p":[],"a":["href","title"],"strong":[],...}
+ * where each value is the array of attribute names allowed for that tag. */
+el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
+el_val_t html_raw(el_val_t s);
+el_val_t html_escape(el_val_t s);
+
+/* ── Filesystem ──────────────────────────────────────────────────────────── */
+
+el_val_t fs_read(el_val_t path);
+el_val_t fs_write(el_val_t path, el_val_t content);
+el_val_t fs_list(el_val_t path);
+el_val_t fs_list_json(el_val_t path);
+el_val_t fs_exists(el_val_t path);
+el_val_t fs_mkdir(el_val_t path); /* mkdir -p, mode 0755 */
+
+/* Length-explicit binary write. `length` is an Int (el_val_t holding the
+ * byte count). The caller knows the length from context — typically because
+ * `bytes` came from base64_decode (which produces a magic-tagged binary
+ * buffer with embedded NULs possible) and the caller already tracks the
+ * decoded length, OR because the bytes came from a fixed-size source
+ * (sha256_bytes = 32, hmac_sha256_bytes = 32). Bypasses strlen entirely.
+ *
+ * Returns 1 on success, 0 on failure (invalid path, can't open, partial
+ * write, negative length). On partial-write failure, the file is removed
+ * so callers cannot read back a truncated artefact. */
+el_val_t fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t length);
+
+/* ── JSON ────────────────────────────────────────────────────────────────── */
+
+el_val_t json_get(el_val_t json, el_val_t key);
+el_val_t json_parse(el_val_t s);
+el_val_t json_stringify(el_val_t v);
+el_val_t json_get_string(el_val_t json_str, el_val_t key);
+el_val_t json_get_int(el_val_t json_str, el_val_t key);
+el_val_t json_get_float(el_val_t json_str, el_val_t key);
+el_val_t json_get_bool(el_val_t json_str, el_val_t key);
+el_val_t json_get_raw(el_val_t json_str, el_val_t key);
+el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
+el_val_t json_array_len(el_val_t json_str);
+el_val_t json_array_get(el_val_t json_str, el_val_t index);
+el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
+el_val_t json_escape_string(el_val_t sv);
+el_val_t json_build_object(el_val_t kvs);
+el_val_t json_build_array(el_val_t items);
+
+/* ── Time ────────────────────────────────────────────────────────────────── */
+
+el_val_t time_now(void);
+el_val_t time_now_utc(void);
+el_val_t sleep_secs(el_val_t secs);
+el_val_t sleep_ms(el_val_t ms);
+el_val_t time_format(el_val_t ts, el_val_t fmt);
+el_val_t time_to_parts(el_val_t ts);
+el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz);
+el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit);
+el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit);
+el_val_t now_ns(void);
+
+/* ── Instant + Duration: first-class temporal types ──────────────────────────
+ * Both types share the el_val_t (int64) slot. Instants are nanoseconds
+ * since the Unix epoch; Durations are signed nanoseconds. Type discipline
+ * is enforced at codegen-time: BinOps on names registered as Instant or
+ * Duration route through the typed wrappers below; mismatches like
+ * Instant+Instant become #error at the C compiler.
+ *
+ * Postfix literals — `30.seconds`, `1.hour`, `500.millis`, `30.nanos` — are
+ * recognised by the parser as DurationLit AST nodes and lowered to literal
+ * int64 nanoseconds at codegen time. The runtime never sees the units. */
+
+el_val_t el_now_instant(void);
+el_val_t now(void);
+el_val_t unix_seconds(el_val_t n);
+el_val_t unix_millis(el_val_t n);
+el_val_t instant_from_iso8601(el_val_t s);
+
+el_val_t el_duration_from_nanos(el_val_t ns);
+el_val_t duration_seconds(el_val_t n);
+el_val_t duration_millis(el_val_t n);
+el_val_t duration_nanos(el_val_t n);
+
+el_val_t el_instant_add_dur(el_val_t inst, el_val_t dur);
+el_val_t el_instant_sub_dur(el_val_t inst, el_val_t dur);
+el_val_t el_instant_diff(el_val_t a, el_val_t b);
+el_val_t el_duration_add(el_val_t a, el_val_t b);
+el_val_t el_duration_sub(el_val_t a, el_val_t b);
+el_val_t el_duration_scale(el_val_t dur, el_val_t scalar);
+el_val_t el_duration_div(el_val_t dur, el_val_t scalar);
+
+el_val_t el_instant_lt(el_val_t a, el_val_t b);
+el_val_t el_instant_le(el_val_t a, el_val_t b);
+el_val_t el_instant_gt(el_val_t a, el_val_t b);
+el_val_t el_instant_ge(el_val_t a, el_val_t b);
+el_val_t el_instant_eq(el_val_t a, el_val_t b);
+el_val_t el_instant_ne(el_val_t a, el_val_t b);
+el_val_t el_duration_lt(el_val_t a, el_val_t b);
+el_val_t el_duration_le(el_val_t a, el_val_t b);
+el_val_t el_duration_gt(el_val_t a, el_val_t b);
+el_val_t el_duration_ge(el_val_t a, el_val_t b);
+el_val_t el_duration_eq(el_val_t a, el_val_t b);
+el_val_t el_duration_ne(el_val_t a, el_val_t b);
+
+el_val_t instant_to_unix_seconds(el_val_t i);
+el_val_t instant_to_unix_millis(el_val_t i);
+el_val_t instant_to_iso8601(el_val_t i);
+el_val_t duration_to_seconds(el_val_t d);
+el_val_t duration_to_millis(el_val_t d);
+el_val_t duration_to_nanos(el_val_t d);
+
+el_val_t el_sleep_duration(el_val_t dur);
+el_val_t unix_timestamp(void);
+
+el_val_t ttl_cache_set(el_val_t key, el_val_t value);
+el_val_t ttl_cache_get(el_val_t key, el_val_t max_age);
+el_val_t ttl_cache_age(el_val_t key);
+
+/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ─────────────
+ * Phase 1.5 of the time system. Calendar is pluggable: EarthCalendar (IANA
+ * zones, Gregorian, DST) is the user-facing default; MarsCalendar,
+ * CycleCalendar(period), NoCycleCalendar, RelativeCalendar handle non-Earth
+ * domains.
+ *
+ * A Calendar interprets an Instant under a particular cycle convention and
+ * produces a CalendarTime. CalendarTime carries the underlying Instant and
+ * a back-pointer to its Calendar; arithmetic and formatting consult the
+ * Calendar to convert ns since epoch into year/month/day/hour/minute/second
+ * (or sol/phase, or cycle/phase, depending on kind).
+ *
+ * Storage convention: Calendar / CalendarTime / Rhythm / LocalDate /
+ * LocalDateTime are heap-allocated structs whose pointers are cast into
+ * el_val_t. A 24-bit magic header at offset 0 lets the runtime identify
+ * the kind safely. LocalTime is small enough to live in the int64 slot
+ * directly (nanos since midnight, signed). */
+
+/* Zone — opaque IANA zone or fixed offset, used by EarthCalendar.
+ * `zone_id` is either an IANA name ("America/New_York", "UTC") or a fixed
+ * offset string ("+05:30", "-08:00"). The runtime resolves it via tzset()
+ * on first use of the owning EarthCalendar. */
+el_val_t zone(el_val_t id);
+el_val_t zone_utc(void);
+el_val_t zone_local(void);
+el_val_t zone_offset(el_val_t hours, el_val_t minutes);
+
+/* Calendar constructors. Each returns an el_val_t pointer to a heap-
+ * allocated, magic-tagged Calendar struct. Calendars are interned by
+ * (kind, zone_id, period_ns, epoch_ns) so identical constructors return
+ * the same pointer — equality is reference equality. */
+el_val_t earth_calendar(el_val_t z);
+el_val_t earth_calendar_default(void);
+el_val_t mars_calendar(void);
+el_val_t cycle_calendar(el_val_t period_dur);
+el_val_t no_cycle_calendar(void);
+el_val_t relative_calendar(el_val_t epoch_inst);
+
+/* CalendarTime constructors and methods. Returns a heap-allocated struct
+ * whose pointer fits in el_val_t. */
+el_val_t now_in(el_val_t cal);
+el_val_t in_calendar(el_val_t inst, el_val_t cal);
+el_val_t cal_format(el_val_t ct, el_val_t pattern);
+el_val_t cal_to_instant(el_val_t ct);
+el_val_t cal_cycle_phase(el_val_t ct);
+el_val_t cal_in(el_val_t ct, el_val_t cal);
+
+/* LocalDate / LocalTime / LocalDateTime — calendar-agnostic value types.
+ * LocalTime carries nanoseconds since midnight as a signed int64 directly
+ * in the el_val_t slot (no allocation). LocalDate / LocalDateTime are
+ * heap-allocated structs with magic headers. */
+el_val_t local_date(el_val_t y, el_val_t m, el_val_t d);
+el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns);
+el_val_t local_datetime(el_val_t date, el_val_t time);
+el_val_t zoned(el_val_t date, el_val_t time, el_val_t cal);
+
+el_val_t local_date_year(el_val_t ld);
+el_val_t local_date_month(el_val_t ld);
+el_val_t local_date_day(el_val_t ld);
+el_val_t local_time_hour(el_val_t lt);
+el_val_t local_time_minute(el_val_t lt);
+el_val_t local_time_second(el_val_t lt);
+el_val_t local_time_nanos(el_val_t lt);
+
+el_val_t el_local_date_add_dur(el_val_t ld, el_val_t dur);
+el_val_t el_local_time_add_dur(el_val_t lt, el_val_t dur);
+el_val_t el_local_date_lt(el_val_t a, el_val_t b);
+el_val_t el_local_date_eq(el_val_t a, el_val_t b);
+
+/* Rhythm — pluggable recurrence AST. Returns a heap-allocated struct
+ * pointer in el_val_t; rhythms are immutable so callers may share them. */
+el_val_t rhythm_cycle_start(void);
+el_val_t rhythm_cycle_phase(el_val_t phase);
+el_val_t rhythm_duration(el_val_t d);
+el_val_t rhythm_session_start(void);
+el_val_t rhythm_event(el_val_t name);
+el_val_t rhythm_and(el_val_t a, el_val_t b);
+el_val_t rhythm_or(el_val_t a, el_val_t b);
+el_val_t rhythm_weekday(el_val_t day);
+el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute);
+el_val_t rhythm_next_after(el_val_t r, el_val_t after, el_val_t cal);
+el_val_t rhythm_matches(el_val_t r, el_val_t ct);
+
+/* ── UUID ────────────────────────────────────────────────────────────────── */
+
+el_val_t uuid_new(void);
+el_val_t uuid_v4(void);
+
+/* ── Environment ─────────────────────────────────────────────────────────── */
+
+el_val_t env(el_val_t key);
+
+/* ── In-process state K/V ────────────────────────────────────────────────── */
+
+el_val_t state_set(el_val_t key, el_val_t value);
+el_val_t state_get(el_val_t key);
+el_val_t state_del(el_val_t key);
+el_val_t state_keys(void);
+el_val_t state_has(el_val_t key);
+el_val_t state_get_or(el_val_t key, el_val_t default_val);
+
+/* ── Float formatting ────────────────────────────────────────────────────── */
+
+el_val_t float_to_str(el_val_t f);
+el_val_t int_to_float(el_val_t n);
+el_val_t float_to_int(el_val_t f);
+el_val_t format_float(el_val_t f, el_val_t decimals);
+el_val_t decimal_round(el_val_t f, el_val_t decimals);
+el_val_t str_to_float(el_val_t s);
+
+/* ── Math (Float-aware) ──────────────────────────────────────────────────── */
+
+el_val_t math_sqrt(el_val_t f);
+el_val_t math_log(el_val_t f);
+el_val_t math_ln(el_val_t f);
+el_val_t math_sin(el_val_t f);
+el_val_t math_cos(el_val_t f);
+el_val_t math_pi(void);
+
+/* ── String additions ────────────────────────────────────────────────────── */
+
+el_val_t str_index_of(el_val_t s, el_val_t sub);
+el_val_t str_split(el_val_t s, el_val_t sep);
+el_val_t str_char_at(el_val_t s, el_val_t i);
+el_val_t str_char_code(el_val_t s, el_val_t i);
+el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad);
+el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad);
+el_val_t str_format(el_val_t fmt, el_val_t data);
+el_val_t str_lower(el_val_t s);
+el_val_t str_upper(el_val_t s);
+
+/* ── Text-processing primitives (Phase 1: byte/codepoint, ASCII char classes)
+ * Phase 2 (filed): Unicode-grapheme awareness, NFC/NFD normalization, regex.
+ * is_* predicates: empty input returns false; multi-char requires ALL bytes
+ * to match. ASCII ranges only in Phase 1. */
+
+/* Counting */
+el_val_t str_count(el_val_t s, el_val_t sub); /* non-overlapping */
+el_val_t str_count_chars(el_val_t s); /* codepoint count */
+el_val_t str_count_bytes(el_val_t s); /* alias of str_len */
+el_val_t str_count_lines(el_val_t s);
+el_val_t str_count_words(el_val_t s);
+el_val_t str_count_letters(el_val_t s); /* ASCII [A-Za-z] */
+el_val_t str_count_digits(el_val_t s); /* ASCII [0-9] */
+
+/* Find / position */
+el_val_t str_index_of_all(el_val_t s, el_val_t sub); /* [Int] of byte offsets */
+el_val_t str_last_index_of(el_val_t s, el_val_t sub);
+el_val_t str_find_chars(el_val_t s, el_val_t any_of); /* first idx of any ch */
+
+/* Transform */
+el_val_t str_repeat(el_val_t s, el_val_t n);
+el_val_t str_reverse(el_val_t s); /* by codepoint */
+el_val_t str_strip_prefix(el_val_t s, el_val_t prefix);
+el_val_t str_strip_suffix(el_val_t s, el_val_t suffix);
+el_val_t str_strip_chars(el_val_t s, el_val_t chars);
+el_val_t str_lstrip(el_val_t s);
+el_val_t str_rstrip(el_val_t s);
+
+/* Char classification (Bool) */
+el_val_t is_letter(el_val_t s);
+el_val_t is_digit(el_val_t s);
+el_val_t is_alphanumeric(el_val_t s);
+el_val_t is_whitespace(el_val_t s);
+el_val_t is_punctuation(el_val_t s);
+el_val_t is_uppercase(el_val_t s);
+el_val_t is_lowercase(el_val_t s);
+
+/* Split / join */
+el_val_t str_split_lines(el_val_t s);
+el_val_t str_split_chars(el_val_t s); /* alias of native_string_chars */
+el_val_t str_split_n(el_val_t s, el_val_t sep, el_val_t n);
+el_val_t str_join(el_val_t list, el_val_t sep); /* alias of list_join */
+
+/* ── List additions ──────────────────────────────────────────────────────── */
+
+el_val_t list_push(el_val_t list, el_val_t elem);
+el_val_t list_push_front(el_val_t list, el_val_t elem);
+el_val_t list_join(el_val_t list, el_val_t sep);
+el_val_t list_range(el_val_t start, el_val_t end);
+
+/* ── Bool helpers ────────────────────────────────────────────────────────── */
+
+el_val_t bool_to_str(el_val_t b);
+
+/* ── Numeric parsing ─────────────────────────────────────────────────────── */
+
+el_val_t parse_int(el_val_t s, el_val_t default_val);
+
+/* ── Process ─────────────────────────────────────────────────────────────── */
+
+el_val_t exit_program(el_val_t code);
+el_val_t getpid_now(void);
+
+/* Self-terminating memory guard. Reads ELC_MAX_MEM_MB (default 512) and
+ * exits with code 1 if resident memory exceeds the limit. Call periodically
+ * during long compilation loops (e.g. after each function is compiled).
+ * Returns 0 when memory is within bounds. */
+el_val_t el_mem_check(void);
+
+/* ── CGI identity ─────────────────────────────────────────────────────────────
+ * Called at the start of main() in CGI programs (those with a `cgi {}` block).
+ * Records the program's DHARMA identity before any other code executes. */
+
+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);
+
+/* ── DHARMA network builtins ─────────────────────────────────────────────────
+ * Available to CGI programs (declared with a `cgi {}` block).
+ *
+ * Peers are addressed by `dharma_id` of the form
+ * "@" e.g. "ntn-genesis@http://localhost:7770"
+ * If the @ portion is omitted, transport defaults to
+ * "http://localhost:7770" (the local CGI daemon assumption).
+ *
+ * Wire protocol (all peers expose):
+ * POST /dharma/recv { channel, from, content } → response body
+ * POST /dharma/event { type, payload, source, timestamp }
+ * POST /api/activate { query } → list of nodes
+ *
+ * Hosting application's responsibility: an El program with a `cgi {}` block
+ * runs http_serve() with its own request handler; that handler should route
+ * "/dharma/event" requests by calling el_runtime_dharma_event_arrive() so
+ * incoming events feed dharma_field() queues. The runtime itself does not
+ * intercept any /dharma path. */
+
+el_val_t dharma_connect(el_val_t cgi_id);
+el_val_t dharma_send(el_val_t channel, el_val_t content);
+el_val_t dharma_activate(el_val_t query);
+void dharma_emit(el_val_t event_type, el_val_t payload);
+el_val_t dharma_field(el_val_t event_type);
+void dharma_strengthen(el_val_t cgi_id, el_val_t weight);
+el_val_t dharma_relationship(el_val_t cgi_id);
+el_val_t dharma_peers(void);
+
+/* Public C API: called by an El program's HTTP handler when a /dharma/event
+ * request arrives. Pushes onto the per-event-type queue and signals any
+ * pending dharma_field() blockers. All three arguments must be NUL-terminated
+ * C strings (or NULL — then treated as empty). */
+void el_runtime_dharma_event_arrive(const char* event_type,
+ const char* payload,
+ const char* source);
+
+/* ── Engram local graph primitives ───────────────────────────────────────────
+ * Operate on the CGI's local Engram knowledge graph.
+ * `engram_activate` queries the local graph only; `dharma_activate` is
+ * network-wide across all connected CGI graphs. */
+
+el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience);
+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);
+/* Layered consciousness — see el_runtime.c for the layered architecture
+ * design notes (search "Layered consciousness architecture"). The five
+ * canonical layers (safety / core-identity / domain-knowledge / imprint /
+ * suit) are seeded automatically; engram_add_layer extends the registry
+ * with imprint or suit overlays at runtime. Nodes default to layer 1
+ * (core-identity) when created via engram_node / engram_node_full. */
+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);
+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);
+el_val_t engram_remove_layer(el_val_t layer_id);
+el_val_t engram_list_layers(void);
+el_val_t engram_get_node(el_val_t id);
+void engram_strengthen(el_val_t node_id);
+void engram_forget(el_val_t node_id);
+el_val_t engram_node_count(void);
+el_val_t engram_search(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
+void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
+el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id);
+el_val_t engram_neighbors(el_val_t node_id);
+el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_edge_count(void);
+/* Three-pass activation: background fan-out → working-memory promotion →
+ * Layer 0 override. See "Three-pass activation" in el_runtime.c. */
+el_val_t engram_activate(el_val_t query, el_val_t depth);
+el_val_t engram_save(el_val_t path);
+el_val_t engram_load(el_val_t path);
+
+/* JSON-string accessors — return pre-serialized JSON so HTTP handlers
+ * can pass results straight through without round-tripping ElList/ElMap
+ * through json_stringify. */
+el_val_t engram_get_node_json(el_val_t id);
+el_val_t engram_search_json(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
+el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_activate_json(el_val_t query, el_val_t depth);
+el_val_t engram_stats_json(void);
+el_val_t engram_list_layers_json(void);
+/* engram_compile_layered_json — produce a prompt-ready text block split
+ * into "[LAYER 0 — STRUCTURAL]" (non-suppressible layers, sacred fire)
+ * and "[ENGRAM CONTEXT]" (standard suppressible layers). Returns "" if
+ * no nodes promoted to working memory. */
+el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth);
+
+/* ── LLM (Anthropic API client) ─────────────────────────────────────────────
+ * All functions call https://api.anthropic.com/v1/messages with the API key
+ * from env ANTHROPIC_API_KEY. Default model when empty: claude-sonnet-4-5. */
+
+el_val_t llm_call(el_val_t model, el_val_t prompt);
+el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt);
+el_val_t llm_call_agentic(el_val_t model, el_val_t system, el_val_t user, el_val_t tools);
+el_val_t llm_vision(el_val_t model, el_val_t system, el_val_t prompt, el_val_t image_url_or_b64);
+el_val_t llm_models(void);
+
+/* Register a tool handler by name. The handler is looked up via dlsym
+ * (mirroring http_set_handler), so any El `fn (input)` compiles to
+ * a global C symbol that this function can locate at runtime.
+ * Handler signature: `el_val_t handler(el_val_t input_json)` — receives
+ * the tool input as a JSON-string el_val_t and returns a JSON-string
+ * el_val_t result. Used by llm_call_agentic. */
+void llm_register_tool(el_val_t name, el_val_t handler_fn_name);
+
+/* ── args() ─────────────────────────────────────────────────────────────────
+ * Provides access to command-line arguments passed to the program.
+ * Populated by el_runtime_init_args() before main() runs. */
+
+el_val_t args(void);
+void el_runtime_init_args(int argc, char** argv);
+
+/* ── Crypto primitives ─────────────────────────────────────────────────────
+ * SHA-256, HMAC-SHA-256, and base64 (standard + URL-safe).
+ * Self-contained — no OpenSSL/libcrypto dependency. The implementations are
+ * adapted from public-domain reference code (Brad Conte / RFC 4648).
+ *
+ * Bytes-returning variants (sha256_bytes, hmac_sha256_bytes) return a string
+ * value whose contents are raw binary; callers usually feed these into
+ * base64_encode. Note that el_val_t strings are NUL-terminated by convention,
+ * so the binary payload may contain embedded NULs — pass it directly into
+ * base64_encode (which uses an explicit length) rather than treating it as
+ * a printable C string.
+ *
+ * The "base64" variants emit/accept RFC 4648 standard alphabet with padding.
+ * The "base64url" variants use URL-safe alphabet (`-`/`_`) with no padding,
+ * as used in JWTs. */
+
+el_val_t sha256_hex(el_val_t input);
+el_val_t sha256_bytes(el_val_t input);
+el_val_t hmac_sha256_hex(el_val_t key, el_val_t message);
+el_val_t hmac_sha256_bytes(el_val_t key, el_val_t message);
+el_val_t base64_encode(el_val_t input);
+el_val_t base64_decode(el_val_t input);
+el_val_t base64url_encode(el_val_t input);
+el_val_t base64url_decode(el_val_t input);
+
+/* Length-aware variants (internal — exposed for the rare caller that already
+ * has a known-length binary buffer and doesn't want to round-trip through
+ * a NUL-terminated el_val_t string). Sha256_bytes and hmac_sha256_bytes feed
+ * these implicitly. */
+el_val_t el_sha256_bytes_n(const unsigned char* data, size_t len);
+el_val_t el_base64_encode_n(const unsigned char* data, size_t len, int url_safe);
+
+/* ── Post-quantum primitives (liboqs-backed) ────────────────────────────────
+ * All inputs/outputs hex-encoded. Algorithm choices:
+ * Signature: CRYSTALS-Dilithium-3 (NIST level 3, balanced)
+ * KEM: CRYSTALS-Kyber-768 (NIST level 3)
+ * Hash: SHA3-256 (Keccak) (PQ-aware protocols favour SHA3 over SHA2)
+ *
+ * If liboqs is not linked (detected via __has_include() at compile
+ * time), the pq_* entry points return a JSON-shaped error string so callers
+ * fail loudly rather than silently fall back to classical schemes:
+ * {"error":"liboqs not linked, post-quantum primitives unavailable"}
+ *
+ * The hybrid handshake pairs X25519 with Kyber-768 per NIST PQ guidance and
+ * CNSA 2.0. Combined shared secret is HKDF-SHA256(x25519_ss || kyber_ss).
+ * Even if Kyber falls, X25519 holds; if X25519 falls under quantum attack,
+ * Kyber holds. SHA3-256 also remains usable independent of liboqs (the
+ * Keccak permutation is PQ-OK as a primitive). */
+
+el_val_t pq_keygen_signature(void);
+el_val_t pq_sign(el_val_t secret_key_hex, el_val_t message);
+el_val_t pq_verify(el_val_t public_key_hex, el_val_t message, el_val_t signature_hex);
+
+el_val_t pq_kem_keygen(void);
+el_val_t pq_kem_encaps(el_val_t public_key_hex);
+el_val_t pq_kem_decaps(el_val_t secret_key_hex, el_val_t ciphertext_hex);
+
+el_val_t pq_hybrid_keygen(void);
+el_val_t pq_hybrid_handshake(el_val_t remote_pub_combined);
+
+el_val_t sha3_256_hex(el_val_t input);
+
+/* ── AEAD: AES-256-GCM (libcrypto-backed) ───────────────────────────────────
+ * Symmetric authenticated encryption used to wrap envelopes after a KEM
+ * handshake. Caller MUST supply a 32-byte key (64 hex chars) — typically the
+ * Kyber-768 / hybrid shared_secret, optionally normalized via SHA3-256.
+ *
+ * aead_encrypt returns a JSON map {"nonce":"...","ciphertext":"..."} where
+ * ciphertext is the AES-256-GCM output with the 16-byte auth tag appended.
+ * Nonce is a fresh 12-byte CSPRNG draw — callers never pick the nonce, which
+ * structurally rules out the GCM nonce-reuse footgun.
+ *
+ * aead_decrypt returns the plaintext String, or "" on any failure (including
+ * auth-tag mismatch). Callers MUST check for "" before trusting the result. */
+el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext);
+el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex);
+
+/* ── Native VM builtin aliases (for compiled El source) ─────────────────────
+ * These match the El VM's native_* builtins so that El source compiled
+ * to C can call the same names without modification. */
+
+el_val_t native_list_get(el_val_t list, el_val_t index);
+el_val_t native_list_len(el_val_t list);
+el_val_t native_list_append(el_val_t list, el_val_t elem);
+el_val_t native_list_empty(void);
+el_val_t native_list_clone(el_val_t list);
+el_val_t native_string_chars(el_val_t s);
+el_val_t native_int_to_str(el_val_t n);
+
+/* ── Method-call shorthand aliases ──────────────────────────────────────────
+ * The El method-call convention `obj.method(args)` compiles to
+ * `method(obj, args)`. These aliases expose the runtime functions under
+ * the short names that result from method calls in El source.
+ *
+ * Example: `myList.append(x)` → `append(myList, x)` (calls this alias)
+ * `myList.len()` → `len(myList)` (calls this alias) */
+
+el_val_t append(el_val_t list, el_val_t elem); /* el_list_append */
+el_val_t len(el_val_t list); /* el_list_len */
+el_val_t get(el_val_t list, el_val_t index); /* el_list_get */
+el_val_t map_get(el_val_t map, el_val_t key); /* el_map_get */
+el_val_t map_set(el_val_t map, el_val_t key, el_val_t value); /* el_map_set */
+
+/* ── OTLP/HTTP Observability ─────────────────────────────────────────────── */
+/* See bottom of el_runtime.c for the implementation.
+ * Configured by env vars OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION.
+ * No-op when OTLP_ENDPOINT is unset. Drop-on-failure semantics. */
+/* ── Subprocess execution ────────────────────────────────────────────────── */
+el_val_t exec_command(el_val_t cmd); /* run shell command, return exit code */
+el_val_t exec_capture(el_val_t cmd); /* run shell command, capture stdout */
+el_val_t exec(el_val_t cmd); /* exec(cmd) → stdout String (30s timeout) */
+el_val_t exec_bg(el_val_t cmd); /* exec_bg(cmd) → PID String (non-blocking) */
+
+/* ── Stdout redirection (used by compiler JS pipeline) ───────────────────── */
+el_val_t stdout_to_file(el_val_t path); /* redirect process stdout to a file */
+el_val_t stdout_restore(void); /* restore process stdout to terminal */
+
+el_val_t emit_log(el_val_t level, el_val_t msg, el_val_t fields_json);
+el_val_t emit_metric(el_val_t name, el_val_t value, el_val_t tags_json);
+el_val_t trace_span_start(el_val_t name);
+el_val_t trace_span_end(el_val_t span_handle);
+el_val_t emit_event(el_val_t name, el_val_t duration_ms);
+
+el_val_t __thread_create(el_val_t fn_name_v, el_val_t arg_v);
+el_val_t __thread_join(el_val_t tid_v);
+
+/* ── __ prefixed aliases (self-hosting compiler ABI) ─────────────────────────
+ * The El self-hosting compiler emits calls to __-prefixed names. These are
+ * forwarding wrappers around the existing el_runtime functions above. */
+
+/* I/O */
+el_val_t __println(el_val_t s);
+el_val_t __print(el_val_t s);
+el_val_t __readline(void);
+
+/* String */
+el_val_t __int_to_str(el_val_t n);
+el_val_t __str_to_int(el_val_t s);
+el_val_t __float_to_str(el_val_t f);
+el_val_t __str_to_float(el_val_t s);
+el_val_t __str_len(el_val_t s);
+el_val_t __str_char_at(el_val_t s, el_val_t i);
+el_val_t __str_cmp(el_val_t a, el_val_t b);
+el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n);
+el_val_t __str_concat_raw(el_val_t a, el_val_t b);
+el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end);
+el_val_t __str_alloc(el_val_t n);
+el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c);
+
+/* URL encoding */
+el_val_t __url_encode(el_val_t s);
+el_val_t __url_decode(el_val_t s);
+
+/* Environment */
+el_val_t __env_get(el_val_t key);
+
+/* Subprocess */
+el_val_t __exec(el_val_t cmd);
+el_val_t __exec_bg(el_val_t cmd);
+
+/* Process */
+el_val_t __exit_program(el_val_t code);
+
+/* Filesystem */
+el_val_t __fs_exists(el_val_t path);
+el_val_t __fs_mkdir(el_val_t path);
+el_val_t __fs_read(el_val_t path);
+el_val_t __fs_write(el_val_t path, el_val_t content);
+el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n);
+el_val_t __fs_list_raw(el_val_t path);
+
+/* HTTP server */
+el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+el_val_t __http_serve(el_val_t port, el_val_t handler);
+el_val_t __http_serve_v2(el_val_t port, el_val_t handler);
+
+/* HTTP conn fd / SSE (weak; overridden by el_seed.c when linked together) */
+el_val_t __http_conn_fd(void);
+el_val_t __http_sse_open(el_val_t conn_id);
+el_val_t __http_sse_send(el_val_t conn_id, el_val_t data);
+el_val_t __http_sse_close(el_val_t conn_id);
+
+/* HTTP client (requires HAVE_CURL; stubs provided for no-curl builds) */
+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);
+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);
+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);
+
+/* JSON */
+el_val_t __json_array_get(el_val_t json, el_val_t index);
+el_val_t __json_array_get_string(el_val_t json, el_val_t index);
+el_val_t __json_array_len(el_val_t json);
+el_val_t __json_get(el_val_t json, el_val_t key);
+el_val_t __json_get_raw(el_val_t json, el_val_t key);
+el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value);
+el_val_t __json_parse_map(el_val_t json_str);
+el_val_t __json_stringify_val(el_val_t val);
+
+/* Hashing */
+el_val_t __sha256_hex(el_val_t s);
+
+/* State K/V */
+el_val_t __state_del(el_val_t key);
+el_val_t __state_get(el_val_t key);
+el_val_t __state_keys(void);
+el_val_t __state_set(el_val_t key, el_val_t val);
+
+/* UUID */
+el_val_t __uuid_v4(void);
+
+/* Args */
+el_val_t __args_json(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/ui/examples/native-hello-android/app/src/main/jni/el_seed.c b/ui/examples/native-hello-android/app/src/main/jni/el_seed.c
new file mode 100644
index 0000000..fc9fbba
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/el_seed.c
@@ -0,0 +1,1942 @@
+/*
+ * el_seed.c — El language seed runtime: minimal C OS boundary
+ *
+ * This file exposes all OS-boundary primitives that El programs need, under
+ * the __ prefix convention. It is self-contained: all allocators, arena
+ * management, and el_request_start / el_request_end are defined here.
+ *
+ * Threading: __thread_create / __thread_join use dlsym(RTLD_DEFAULT) to look
+ * up El function symbols at runtime. This is the foundation of El's parallelism.
+ *
+ * Link: cc -std=c11 -I el-compiler/runtime -lcurl -lpthread \
+ * -o .c el_seed.c
+ */
+
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include "el_seed.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* ── Private allocator ───────────────────────────────────────────────────── */
+/*
+ * el_seed.c carries its own arena for per-request allocation tracking.
+ * The arena is reset at el_request_start / el_request_end, which are defined
+ * below and delegate to seed_request_start / seed_request_end.
+ */
+
+#define SEED_ARENA_INITIAL 512
+
+typedef struct {
+ char** ptrs;
+ size_t count;
+ size_t cap;
+} SeedArena;
+
+static _Thread_local SeedArena _seed_arena = {NULL, 0, 0};
+static _Thread_local int _seed_arena_on = 0;
+
+static void seed_arena_track(char* p) {
+ if (!_seed_arena_on || !p) return;
+ if (_seed_arena.count >= _seed_arena.cap) {
+ size_t nc = _seed_arena.cap == 0 ? SEED_ARENA_INITIAL : _seed_arena.cap * 2;
+ char** g = realloc(_seed_arena.ptrs, nc * sizeof(char*));
+ if (!g) return;
+ _seed_arena.ptrs = g;
+ _seed_arena.cap = nc;
+ }
+ _seed_arena.ptrs[_seed_arena.count++] = p;
+}
+
+static void seed_request_start(void) {
+ _seed_arena.count = 0;
+ _seed_arena_on = 1;
+}
+
+static void seed_request_end(void) {
+ _seed_arena_on = 0;
+ for (size_t i = 0; i < _seed_arena.count; i++) free(_seed_arena.ptrs[i]);
+ _seed_arena.count = 0;
+}
+
+/* el_request_start / el_request_end — formerly defined in el_runtime.c.
+ * Now self-contained in el_seed.c, delegating to the seed arena. */
+void el_request_start(void) { seed_request_start(); }
+void el_request_end(void) { seed_request_end(); }
+
+/* Persistent alloc — bypasses arena (state, engram internals). */
+static char* seed_strdup_persist(const char* s) {
+ if (!s) return strdup("");
+ return strdup(s);
+}
+
+static char* seed_strdup(const char* s) {
+ char* p = strdup(s ? s : "");
+ seed_arena_track(p);
+ return p;
+}
+
+static char* seed_strbuf(size_t n) {
+ char* p = malloc(n + 1);
+ if (!p) { fputs("el_seed: out of memory\n", stderr); exit(1); }
+ p[0] = '\0';
+ seed_arena_track(p);
+ return p;
+}
+
+static el_val_t seed_wrap_str(char* s) { return EL_STR(s); }
+
+/* ── String primitives ───────────────────────────────────────────────────── */
+
+el_val_t __str_len(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ return (el_val_t)strlen(p);
+}
+
+el_val_t __str_char_at(el_val_t s, el_val_t i) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ int64_t len = (int64_t)strlen(p);
+ int64_t idx = (int64_t)i;
+ if (idx < 0 || idx >= len) return 0;
+ return (el_val_t)(unsigned char)p[idx];
+}
+
+el_val_t __str_alloc(el_val_t n) {
+ int64_t sz = (int64_t)n;
+ if (sz < 0) sz = 0;
+ char* buf = seed_strbuf((size_t)sz);
+ memset(buf, 0, (size_t)sz + 1);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
+ char* p = (char*)(uintptr_t)s;
+ if (!p) return s;
+ int64_t len = (int64_t)strlen(p);
+ int64_t idx = (int64_t)i;
+ if (idx < 0 || idx >= len) return s;
+ p[idx] = (char)(unsigned char)(int64_t)c;
+ return s;
+}
+
+el_val_t __str_cmp(el_val_t a, el_val_t b) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ return (el_val_t)strcmp(sa, sb);
+}
+
+el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ return (el_val_t)strncmp(sa, sb, (size_t)(int64_t)n);
+}
+
+el_val_t __str_concat_raw(el_val_t a, el_val_t b) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ size_t la = strlen(sa), lb = strlen(sb);
+ char* out = seed_strbuf(la + lb);
+ memcpy(out, sa, la);
+ memcpy(out + la, sb, lb);
+ out[la + lb] = '\0';
+ return seed_wrap_str(out);
+}
+
+el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end) {
+ const char* p = EL_CSTR(s);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ int64_t len = (int64_t)strlen(p);
+ int64_t st = (int64_t)start;
+ int64_t en = (int64_t)end;
+ if (st < 0) st = 0;
+ if (en > len) en = len;
+ if (st >= en) return seed_wrap_str(seed_strdup(""));
+ int64_t sz = en - st;
+ char* out = seed_strbuf((size_t)sz);
+ memcpy(out, p + st, (size_t)sz);
+ out[sz] = '\0';
+ return seed_wrap_str(out);
+}
+
+el_val_t __int_to_str(el_val_t n) {
+ char buf[32];
+ snprintf(buf, sizeof(buf), "%lld", (long long)(int64_t)n);
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+el_val_t __str_to_int(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ return (el_val_t)atoll(p);
+}
+
+el_val_t __float_to_str(el_val_t f) {
+ char buf[64];
+ snprintf(buf, sizeof(buf), "%g", el_to_float(f));
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+el_val_t __str_to_float(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return el_from_float(0.0);
+ return el_from_float(strtod(p, NULL));
+}
+
+/* ── I/O ─────────────────────────────────────────────────────────────────── */
+
+void __println(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ puts(p ? p : "");
+}
+
+void __print(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (p) fputs(p, stdout);
+}
+
+el_val_t __readline(void) {
+ char buf[4096];
+ if (!fgets(buf, sizeof(buf), stdin)) return seed_wrap_str(seed_strdup(""));
+ size_t len = strlen(buf);
+ if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+/* ── Filesystem ──────────────────────────────────────────────────────────── */
+
+el_val_t __fs_read(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ FILE* f = fopen(p, "rb");
+ if (!f) return seed_wrap_str(seed_strdup(""));
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ rewind(f);
+ if (sz < 0) { fclose(f); return seed_wrap_str(seed_strdup("")); }
+ char* buf = seed_strbuf((size_t)sz);
+ size_t got = fread(buf, 1, (size_t)sz, f);
+ buf[got] = '\0';
+ fclose(f);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __fs_write(el_val_t path, el_val_t content) {
+ const char* p = EL_CSTR(path);
+ const char* c = EL_CSTR(content);
+ if (!p || !c) return 0;
+ FILE* f = fopen(p, "wb");
+ if (!f) return 0;
+ size_t n = strlen(c);
+ size_t w = fwrite(c, 1, n, f);
+ fclose(f);
+ return w == n ? 1 : 0;
+}
+
+el_val_t __fs_exists(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return 0;
+ struct stat st;
+ return (el_val_t)(stat(p, &st) == 0 ? 1 : 0);
+}
+
+el_val_t __fs_list_raw(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ DIR* d = opendir(p);
+ if (!d) return seed_wrap_str(seed_strdup(""));
+ /* Build newline-separated list of filenames. */
+ size_t cap = 4096, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) { closedir(d); return seed_wrap_str(seed_strdup("")); }
+ buf[0] = '\0';
+ struct dirent* e;
+ while ((e = readdir(d)) != NULL) {
+ if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
+ size_t nlen = strlen(e->d_name);
+ while (len + nlen + 2 >= cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); closedir(d); return seed_wrap_str(seed_strdup("")); }
+ buf = g;
+ }
+ if (len > 0) buf[len++] = '\n';
+ memcpy(buf + len, e->d_name, nlen);
+ len += nlen;
+ buf[len] = '\0';
+ }
+ closedir(d);
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __fs_mkdir(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return 0;
+ size_t n = strlen(p);
+ char* buf = malloc(n + 1);
+ if (!buf) return 0;
+ memcpy(buf, p, n + 1);
+ 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) {
+ struct stat st;
+ if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ free(buf); return 0;
+ }
+ }
+ }
+ buf[i] = saved;
+ }
+ }
+ free(buf);
+ return 1;
+}
+
+el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n) {
+ const char* p = EL_CSTR(path);
+ const char* b = EL_CSTR(bytes);
+ int64_t sz = (int64_t)n;
+ if (!p || !b || sz < 0) return 0;
+ FILE* f = fopen(p, "wb");
+ if (!f) return 0;
+ size_t written = (sz > 0) ? fwrite(b, 1, (size_t)sz, f) : 0;
+ int ok1 = (fflush(f) == 0);
+ int ok2 = (fclose(f) == 0);
+ if (!ok1 || !ok2 || written != (size_t)sz) { remove(p); return 0; }
+ return 1;
+}
+
+/* ── HTTP client ─────────────────────────────────────────────────────────── */
+
+typedef struct {
+ char* data;
+ size_t len;
+ size_t cap;
+} SeedHttpBuf;
+
+static void seed_httpbuf_init(SeedHttpBuf* b) {
+ b->cap = 1024; b->len = 0;
+ b->data = malloc(b->cap);
+ if (!b->data) { fputs("el_seed: out of memory\n", stderr); exit(1); }
+ b->data[0] = '\0';
+}
+
+static void seed_httpbuf_append(SeedHttpBuf* b, const void* src, size_t n) {
+ while (b->len + n + 1 > b->cap) b->cap *= 2;
+ b->data = realloc(b->data, b->cap);
+ if (!b->data) { fputs("el_seed: 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 seed_http_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
+ size_t n = size * nmemb;
+ seed_httpbuf_append((SeedHttpBuf*)ud, ptr, n);
+ return n;
+}
+
+/* Build a curl_slist from a JSON object string of header name:value pairs. */
+static struct curl_slist* seed_headers_from_json(const char* hj) {
+ struct curl_slist* h = NULL;
+ if (!hj || !*hj) return NULL;
+ /* Walk key:value pairs at depth 1. Simple parser — same logic as json_find_key
+ * in el_runtime.c but adapted for building curl headers. */
+ const char* p = hj;
+ while (*p && *p != '{') p++;
+ if (*p == '{') p++;
+ while (*p) {
+ while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
+ if (*p == '}' || *p == '\0') break;
+ if (*p != '"') break;
+ /* Parse key */
+ p++;
+ const char* ks = p;
+ while (*p && *p != '"') { if (*p == '\\') p++; p++; }
+ size_t klen = (size_t)(p - ks);
+ if (*p == '"') p++;
+ /* Skip : */
+ while (*p == ' ' || *p == ':') p++;
+ /* Parse value */
+ if (*p != '"') break;
+ p++;
+ const char* vs = p;
+ while (*p && *p != '"') { if (*p == '\\') p++; p++; }
+ size_t vlen = (size_t)(p - vs);
+ if (*p == '"') p++;
+ /* Build "Key: Value" header line */
+ size_t line_len = klen + 2 + vlen + 1;
+ char* line = malloc(line_len);
+ if (line) {
+ memcpy(line, ks, klen);
+ memcpy(line + klen, ": ", 2);
+ memcpy(line + klen + 2, vs, vlen);
+ line[klen + 2 + vlen] = '\0';
+ h = curl_slist_append(h, line);
+ free(line);
+ }
+ }
+ return h;
+}
+
+static el_val_t seed_http_error_json(const char* msg) {
+ if (!msg) msg = "unknown error";
+ size_t n = strlen(msg) * 6 + 20;
+ char* buf = seed_strbuf(n);
+ /* Simple escape: replace " with \" */
+ char* d = buf;
+ *d++ = '{'; *d++ = '"'; *d++ = 'e'; *d++ = 'r'; *d++ = 'r';
+ *d++ = 'o'; *d++ = 'r'; *d++ = '"'; *d++ = ':'; *d++ = '"';
+ for (const char* s = msg; *s; s++) {
+ if (*s == '"' || *s == '\\') *d++ = '\\';
+ *d++ = *s;
+ }
+ *d++ = '"'; *d++ = '}'; *d = '\0';
+ return seed_wrap_str(buf);
+}
+
+el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
+ el_val_t headers_json, el_val_t timeout_ms) {
+ const char* m = EL_CSTR(method);
+ const char* u = EL_CSTR(url);
+ const char* b = EL_CSTR(body);
+ const char* hj = EL_CSTR(headers_json);
+ int64_t tms = (int64_t)timeout_ms;
+ if (tms <= 0) tms = 60000;
+
+ if (!u || !*u) return seed_http_error_json("empty url");
+
+ CURL* c = curl_easy_init();
+ if (!c) return seed_http_error_json("curl_easy_init failed");
+
+ SeedHttpBuf rb; seed_httpbuf_init(&rb);
+ char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
+
+ curl_easy_setopt(c, CURLOPT_URL, u);
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, seed_http_write_cb);
+ curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
+ curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, (long)tms);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
+ curl_easy_setopt(c, CURLOPT_USERAGENT, "el-seed/1.0");
+
+ struct curl_slist* hdrs = seed_headers_from_json(hj);
+ if (hdrs) curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
+
+ if (m && strcmp(m, "POST") == 0) {
+ curl_easy_setopt(c, CURLOPT_POST, 1L);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b ? b : "");
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(b ? strlen(b) : 0));
+ } else if (m && strcmp(m, "PUT") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PUT");
+ if (b) {
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(b));
+ }
+ } else if (m && strcmp(m, "DELETE") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
+ } else if (m && strcmp(m, "PATCH") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH");
+ if (b) {
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(b));
+ }
+ }
+ /* GET is the default */
+
+ CURLcode rc = curl_easy_perform(c);
+ if (hdrs) curl_slist_free_all(hdrs);
+ curl_easy_cleanup(c);
+
+ if (rc != CURLE_OK) {
+ free(rb.data);
+ const char* em = errbuf[0] ? errbuf : curl_easy_strerror(rc);
+ return seed_http_error_json(em);
+ }
+
+ seed_arena_track(rb.data);
+ return seed_wrap_str(rb.data);
+}
+
+static size_t seed_http_file_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
+ return fwrite(ptr, size, nmemb, (FILE*)ud);
+}
+
+el_val_t __http_do_to_file(el_val_t method, el_val_t url, el_val_t body,
+ el_val_t headers_json, el_val_t out_path) {
+ const char* m = EL_CSTR(method);
+ const char* u = EL_CSTR(url);
+ const char* b = EL_CSTR(body);
+ const char* hj = EL_CSTR(headers_json);
+ const char* op = EL_CSTR(out_path);
+
+ if (!u || !*u || !op || !*op) return 0;
+ FILE* f = fopen(op, "wb");
+ if (!f) return 0;
+
+ CURL* c = curl_easy_init();
+ if (!c) { fclose(f); remove(op); return 0; }
+
+ char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
+ curl_easy_setopt(c, CURLOPT_URL, u);
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, seed_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, 60000L);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
+ curl_easy_setopt(c, CURLOPT_USERAGENT, "el-seed/1.0");
+
+ struct curl_slist* hdrs = seed_headers_from_json(hj);
+ if (hdrs) curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
+
+ if (m && strcmp(m, "POST") == 0) {
+ curl_easy_setopt(c, CURLOPT_POST, 1L);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b ? b : "");
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(b ? strlen(b) : 0));
+ }
+
+ CURLcode rc = curl_easy_perform(c);
+ if (hdrs) curl_slist_free_all(hdrs);
+ curl_easy_cleanup(c);
+
+ int ok1 = (fflush(f) == 0);
+ int ok2 = (fclose(f) == 0);
+
+ if (rc != CURLE_OK || !ok1 || !ok2) { remove(op); return 0; }
+ return 1;
+}
+
+/* ── HTTP server ─────────────────────────────────────────────────────────── */
+/* Delegate to el_runtime.c's http_serve / http_serve_v2 via the existing
+ * http_set_handler mechanism. */
+
+/* Forward declarations for el_runtime.c functions called from el_seed.c.
+ * A full #include "el_runtime.h" causes redefinition conflicts (el_seed.h
+ * already defines el_to_float, el_from_float, and several __ wrappers).
+ * Declare only the symbols actually used here. */
+
+/* HTTP server */
+el_val_t http_serve(el_val_t port, el_val_t handler);
+el_val_t http_serve_v2(el_val_t port, el_val_t handler);
+el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+
+/* JSON */
+el_val_t json_get(el_val_t json, el_val_t key);
+el_val_t json_get_raw(el_val_t json_str, el_val_t key);
+el_val_t json_parse(el_val_t s);
+el_val_t json_stringify(el_val_t v);
+el_val_t json_array_len(el_val_t json_str);
+el_val_t json_array_get(el_val_t json_str, el_val_t index);
+el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
+el_val_t json_get_string(el_val_t json_str, el_val_t key);
+el_val_t json_get_int(el_val_t json_str, el_val_t key);
+el_val_t json_get_float(el_val_t json_str, el_val_t key);
+el_val_t json_get_bool(el_val_t json_str, el_val_t key);
+el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
+
+/* URL / HTML */
+el_val_t url_encode(el_val_t s);
+el_val_t url_decode(el_val_t s);
+el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
+
+/* State K/V */
+el_val_t state_set(el_val_t key, el_val_t value);
+el_val_t state_get(el_val_t key);
+el_val_t state_del(el_val_t key);
+el_val_t state_keys(void);
+el_val_t state_get_or(el_val_t key, el_val_t default_val);
+
+/* Engram graph */
+el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience);
+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_json);
+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 importance, el_val_t confidence,
+ el_val_t tier, el_val_t tags_json, el_val_t layer_id);
+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);
+el_val_t engram_remove_layer(el_val_t layer_id);
+el_val_t engram_list_layers(void);
+el_val_t engram_get_node(el_val_t id);
+void engram_strengthen(el_val_t node_id);
+void engram_forget(el_val_t node_id);
+el_val_t engram_node_count(void);
+el_val_t engram_search(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
+void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
+el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id);
+el_val_t engram_neighbors(el_val_t node_id);
+el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_edge_count(void);
+el_val_t engram_activate(el_val_t query, el_val_t depth);
+el_val_t engram_save(el_val_t path);
+el_val_t engram_load(el_val_t path);
+el_val_t engram_get_node_json(el_val_t id);
+el_val_t engram_search_json(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
+el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_activate_json(el_val_t query, el_val_t depth);
+el_val_t engram_stats_json(void);
+el_val_t engram_list_layers_json(void);
+el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth);
+
+void __http_serve(el_val_t port, el_val_t handler_name) {
+ http_serve(port, handler_name);
+}
+
+void __http_serve_v2(el_val_t port, el_val_t handler_name) {
+ http_serve_v2(port, handler_name);
+}
+
+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);
+}
+
+/* ── HTTP SSE — Server-Sent Events streaming ─────────────────────────────── */
+/*
+ * Thread-local file descriptor stashed by http_worker_v2 before calling the
+ * El handler. El SSE builtins read this to get the raw socket fd.
+ *
+ * Lifecycle:
+ * http_worker_v2 sets _tl_http_conn_fd = fd (via el_seed_set_http_conn_fd)
+ * El handler calls __http_conn_fd() → receives that fd
+ * El handler calls __http_sse_open(fd) → sends SSE headers, keeps fd open
+ * El handler calls __http_sse_send(fd, data) → writes "data: ...\n\n"
+ * El handler calls __http_sse_close(fd) → closes the fd
+ * El handler returns "__sse__" sentinel → http_worker_v2 does NOT close fd
+ *
+ * The -1 value means no current connection (guard against misuse outside
+ * a handler context).
+ */
+static __thread int _tl_http_conn_fd = -1;
+
+/* Called by el_runtime.c's http_worker_v2 — not part of the El ABI. */
+void el_seed_set_http_conn_fd(int fd) {
+ _tl_http_conn_fd = fd;
+}
+
+/* __http_conn_fd() — returns the raw fd for the current HTTP connection.
+ * Valid only inside an http_serve_v2 handler before it returns. */
+el_val_t __http_conn_fd(void) {
+ return EL_INT(_tl_http_conn_fd);
+}
+
+/* __http_sse_open(fd) — sends SSE response headers on fd, keeping it open.
+ * Returns 1 on success, 0 on write failure. */
+el_val_t __http_sse_open(el_val_t conn_id) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ static const char sse_headers[] =
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/event-stream\r\n"
+ "Cache-Control: no-cache\r\n"
+ "Connection: keep-alive\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "\r\n";
+ size_t n = sizeof(sse_headers) - 1; /* exclude NUL */
+ size_t sent = 0;
+ while (sent < n) {
+ ssize_t w = write(fd, sse_headers + sent, n - sent);
+ if (w <= 0) return 0;
+ sent += (size_t)w;
+ }
+ return 1;
+}
+
+/* __http_sse_send(fd, data) — writes one SSE event frame: "data: \n\n".
+ * data must not contain newlines. Returns 1 on success, 0 on client disconnect. */
+el_val_t __http_sse_send(el_val_t conn_id, el_val_t data) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ const char* s = EL_CSTR(data);
+ if (!s) s = "";
+ /* Build "data: \n\n" in a single buffer for one write call. */
+ size_t prefix_len = 6; /* "data: " */
+ size_t slen = strlen(s);
+ size_t total = prefix_len + slen + 2; /* + "\n\n" */
+ char* buf = malloc(total + 1);
+ if (!buf) return 0;
+ memcpy(buf, "data: ", 6);
+ memcpy(buf + 6, s, slen);
+ buf[6 + slen] = '\n';
+ buf[6 + slen + 1] = '\n';
+ buf[total] = '\0';
+ size_t sent = 0;
+ int ok = 1;
+ while (sent < total) {
+ ssize_t w = write(fd, buf + sent, total - sent);
+ if (w <= 0) { ok = 0; break; }
+ sent += (size_t)w;
+ }
+ free(buf);
+ return ok ? 1 : 0;
+}
+
+/* __http_sse_close(fd) — closes the SSE connection fd. */
+el_val_t __http_sse_close(el_val_t conn_id) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ close(fd);
+ return 1;
+}
+
+/* ── Threading ───────────────────────────────────────────────────────────── */
+/*
+ * Design:
+ * Static ElThread table (max EL_SEED_MAX_THREADS entries).
+ * __thread_create: pick a free slot, store fn_name + arg, launch pthread.
+ * Worker: dlsym(RTLD_DEFAULT, fn_name) → call as el_val_t fn(el_val_t).
+ * Store result string in slot.result.
+ * __thread_join: pthread_join, return stored result string.
+ *
+ * Thread handle is the slot index (0..EL_SEED_MAX_THREADS-1).
+ * Returns -1 on failure (no slots, dlsym failure, pthread_create failure).
+ *
+ * Each slot is guarded by its own mutex so join/create on different handles
+ * never contend.
+ */
+
+#define EL_SEED_MAX_THREADS 64
+
+typedef el_val_t (*ElFn1)(el_val_t);
+
+typedef struct {
+ int in_use;
+ char* fn_name;
+ char* arg;
+ char* result;
+ pthread_t tid;
+ int done; /* set to 1 by worker before exit */
+} ElThread;
+
+static ElThread _el_threads[EL_SEED_MAX_THREADS];
+static pthread_mutex_t _el_thread_mu = PTHREAD_MUTEX_INITIALIZER;
+
+typedef struct {
+ int slot;
+} ElThreadArg;
+
+static void* el_thread_worker(void* raw) {
+ ElThreadArg* ta = (ElThreadArg*)raw;
+ int slot = ta->slot;
+ free(ta);
+
+ ElThread* t = &_el_threads[slot];
+
+ /* Resolve the El function symbol in the running binary. */
+ void* sym = dlsym(RTLD_DEFAULT, t->fn_name);
+ if (!sym) {
+ pthread_mutex_lock(&_el_thread_mu);
+ t->result = seed_strdup_persist("");
+ t->done = 1;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return NULL;
+ }
+
+ ElFn1 fn = (ElFn1)sym;
+
+ /* Call the El function with the string argument. */
+ el_val_t arg_val = EL_STR(t->arg);
+ el_val_t ret = fn(arg_val);
+
+ /* Persist the result string. */
+ const char* rs = EL_CSTR(ret);
+ char* stored = seed_strdup_persist(rs ? rs : "");
+
+ pthread_mutex_lock(&_el_thread_mu);
+ t->result = stored;
+ t->done = 1;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ return NULL;
+}
+
+el_val_t __thread_create(el_val_t fn_name, el_val_t arg) {
+ const char* fname = EL_CSTR(fn_name);
+ const char* astr = EL_CSTR(arg);
+ if (!fname || !*fname) return (el_val_t)(int64_t)-1;
+ if (!astr) astr = "";
+
+ pthread_mutex_lock(&_el_thread_mu);
+ int slot = -1;
+ for (int i = 0; i < EL_SEED_MAX_THREADS; i++) {
+ if (!_el_threads[i].in_use) { slot = i; break; }
+ }
+ if (slot < 0) {
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+
+ ElThread* t = &_el_threads[slot];
+ t->in_use = 1;
+ t->done = 0;
+ t->fn_name = seed_strdup_persist(fname);
+ t->arg = seed_strdup_persist(astr);
+ t->result = NULL;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ ElThreadArg* ta = malloc(sizeof(ElThreadArg));
+ if (!ta) {
+ pthread_mutex_lock(&_el_thread_mu);
+ free(t->fn_name); free(t->arg);
+ t->in_use = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+ ta->slot = slot;
+
+ if (pthread_create(&t->tid, NULL, el_thread_worker, ta) != 0) {
+ free(ta);
+ pthread_mutex_lock(&_el_thread_mu);
+ free(t->fn_name); free(t->arg);
+ t->in_use = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+
+ return (el_val_t)(int64_t)slot;
+}
+
+el_val_t __thread_join(el_val_t tid) {
+ int64_t slot = (int64_t)tid;
+ if (slot < 0 || slot >= EL_SEED_MAX_THREADS) return seed_wrap_str(seed_strdup(""));
+
+ ElThread* t = &_el_threads[slot];
+
+ pthread_mutex_lock(&_el_thread_mu);
+ if (!t->in_use) {
+ pthread_mutex_unlock(&_el_thread_mu);
+ return seed_wrap_str(seed_strdup(""));
+ }
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ /* Wait for thread to finish. */
+ pthread_join(t->tid, NULL);
+
+ pthread_mutex_lock(&_el_thread_mu);
+ char* res = t->result ? t->result : "";
+ char* copy = seed_strdup(res); /* arena-tracked for caller lifetime */
+ free(t->fn_name);
+ free(t->arg);
+ if (t->result) { free(t->result); t->result = NULL; }
+ t->fn_name = NULL; t->arg = NULL;
+ t->in_use = 0;
+ t->done = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ return seed_wrap_str(copy);
+}
+
+/* ── Mutex pool ──────────────────────────────────────────────────────────── */
+
+#define EL_SEED_MAX_MUTEXES 256
+
+static pthread_mutex_t _el_mutexes[EL_SEED_MAX_MUTEXES];
+static int _el_mutex_init[EL_SEED_MAX_MUTEXES];
+static int _el_mutex_used[EL_SEED_MAX_MUTEXES];
+static pthread_mutex_t _el_mutex_pool_mu = PTHREAD_MUTEX_INITIALIZER;
+
+el_val_t __mutex_new(void) {
+ pthread_mutex_lock(&_el_mutex_pool_mu);
+ int slot = -1;
+ for (int i = 0; i < EL_SEED_MAX_MUTEXES; i++) {
+ if (!_el_mutex_used[i]) { slot = i; break; }
+ }
+ if (slot < 0) {
+ pthread_mutex_unlock(&_el_mutex_pool_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+ _el_mutex_used[slot] = 1;
+ if (!_el_mutex_init[slot]) {
+ pthread_mutex_init(&_el_mutexes[slot], NULL);
+ _el_mutex_init[slot] = 1;
+ }
+ pthread_mutex_unlock(&_el_mutex_pool_mu);
+ return (el_val_t)(int64_t)slot;
+}
+
+void __mutex_lock(el_val_t m) {
+ int64_t slot = (int64_t)m;
+ if (slot < 0 || slot >= EL_SEED_MAX_MUTEXES) return;
+ if (!_el_mutex_init[slot]) return;
+ pthread_mutex_lock(&_el_mutexes[slot]);
+}
+
+void __mutex_unlock(el_val_t m) {
+ int64_t slot = (int64_t)m;
+ if (slot < 0 || slot >= EL_SEED_MAX_MUTEXES) return;
+ if (!_el_mutex_init[slot]) return;
+ pthread_mutex_unlock(&_el_mutexes[slot]);
+}
+
+/* ── Subprocess ──────────────────────────────────────────────────────────── */
+
+el_val_t __exec(el_val_t cmd) {
+ const char* c = EL_CSTR(cmd);
+ if (!c) return seed_wrap_str(seed_strdup(""));
+ FILE* f = popen(c, "r");
+ if (!f) return seed_wrap_str(seed_strdup(""));
+ size_t cap = 4096, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) { pclose(f); return seed_wrap_str(seed_strdup("")); }
+ char tmp[4096];
+ while (fgets(tmp, sizeof(tmp), f)) {
+ size_t n = strlen(tmp);
+ while (len + n + 1 > cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); pclose(f); return seed_wrap_str(seed_strdup("")); }
+ buf = g;
+ }
+ memcpy(buf + len, tmp, n);
+ len += n;
+ buf[len] = '\0';
+ }
+ pclose(f);
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+void __exec_bg(el_val_t cmd) {
+ const char* c = EL_CSTR(cmd);
+ if (!c) return;
+ /* Fork-free: run in background via system() with & appended. */
+ size_t n = strlen(c);
+ char* s = malloc(n + 4);
+ if (!s) return;
+ memcpy(s, c, n);
+ memcpy(s + n, " &", 3);
+ system(s);
+ free(s);
+}
+
+/* ── Environment and process ─────────────────────────────────────────────── */
+
+el_val_t __env_get(el_val_t key) {
+ const char* k = EL_CSTR(key);
+ if (!k) return seed_wrap_str(seed_strdup(""));
+ const char* v = getenv(k);
+ return seed_wrap_str(seed_strdup(v ? v : ""));
+}
+
+void __exit_program(el_val_t code) {
+ exit((int)(int64_t)code);
+}
+
+/* ── args_json ────────────────────────────────────────────────────────────── */
+
+static int _seed_argc = 0;
+static char** _seed_argv = NULL;
+
+void el_seed_init_args(int argc, char** argv) {
+ _seed_argc = argc;
+ _seed_argv = argv;
+}
+
+el_val_t __args_json(void) {
+ /* Return ["arg1","arg2",...] as a JSON string. Skip argv[0] (program name). */
+ size_t cap = 256, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) return seed_wrap_str(seed_strdup("[]"));
+ buf[len++] = '[';
+ int first = 1;
+ for (int i = 1; i < _seed_argc; i++) {
+ const char* a = _seed_argv[i];
+ /* Estimate: each arg needs at most strlen*6+4 bytes (worst case escape) */
+ size_t need = strlen(a) * 6 + 8;
+ while (len + need + 2 > cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); return seed_wrap_str(seed_strdup("[]")); }
+ buf = g;
+ }
+ if (!first) buf[len++] = ',';
+ first = 0;
+ buf[len++] = '"';
+ for (const char* p = a; *p; p++) {
+ unsigned char c = (unsigned char)*p;
+ if (c == '"') { buf[len++] = '\\'; buf[len++] = '"'; }
+ else if (c == '\\') { buf[len++] = '\\'; buf[len++] = '\\'; }
+ else if (c == '\n') { buf[len++] = '\\'; buf[len++] = 'n'; }
+ else if (c == '\r') { buf[len++] = '\\'; buf[len++] = 'r'; }
+ else if (c == '\t') { buf[len++] = '\\'; buf[len++] = 't'; }
+ else buf[len++] = (char)c;
+ }
+ buf[len++] = '"';
+ }
+ buf[len++] = ']';
+ buf[len] = '\0';
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+/* ── Time ────────────────────────────────────────────────────────────────── */
+
+el_val_t __time_now_ns(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);
+ return (el_val_t)((int64_t)tv.tv_sec * 1000000000LL + (int64_t)tv.tv_usec * 1000LL);
+}
+
+void __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);
+}
+
+/* ── UUID ────────────────────────────────────────────────────────────────── */
+
+static int _seed_uuid_seeded = 0;
+
+el_val_t __uuid_v4(void) {
+ if (!_seed_uuid_seeded) {
+ srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&_seed_uuid_seeded);
+ _seed_uuid_seeded = 1;
+ }
+ unsigned char b[16];
+ for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xff);
+ b[6] = (b[6] & 0x0f) | 0x40; /* version 4 */
+ b[8] = (b[8] & 0x3f) | 0x80; /* RFC 4122 variant */
+ 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 seed_wrap_str(seed_strdup(buf));
+}
+
+/* ── Math ────────────────────────────────────────────────────────────────── */
+
+el_val_t __sqrt_f(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
+el_val_t __log_f(el_val_t f) { return el_from_float(log10(el_to_float(f))); }
+el_val_t __ln_f(el_val_t f) { return el_from_float(log(el_to_float(f))); }
+el_val_t __sin_f(el_val_t f) { return el_from_float(sin(el_to_float(f))); }
+el_val_t __cos_f(el_val_t f) { return el_from_float(cos(el_to_float(f))); }
+el_val_t __pi_f(void) { return el_from_float(3.14159265358979323846); }
+
+/* ── JSON — thin wrappers around el_runtime.c implementations ────────────── */
+
+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_str, el_val_t key) { return json_get_raw(json_str, key); }
+el_val_t __json_parse(el_val_t s) { return json_parse(s); }
+el_val_t __json_stringify(el_val_t v) { return json_stringify(v); }
+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); }
+el_val_t __json_array_len(el_val_t json_str) { return json_array_len(json_str); }
+el_val_t __json_array_get(el_val_t json_str, el_val_t index) { return json_array_get(json_str, index); }
+el_val_t __json_array_get_string(el_val_t json_str, el_val_t index) { return json_array_get_string(json_str, index); }
+el_val_t __json_get_string(el_val_t json_str, el_val_t key) { return json_get_string(json_str, key); }
+el_val_t __json_get_int(el_val_t json_str, el_val_t key) { return json_get_int(json_str, key); }
+el_val_t __json_get_float(el_val_t json_str, el_val_t key) { return json_get_float(json_str, key); }
+el_val_t __json_get_bool(el_val_t json_str, el_val_t key) { return json_get_bool(json_str, key); }
+el_val_t __json_set(el_val_t json_str, el_val_t key, el_val_t value) { return json_set(json_str, key, value); }
+
+/* ── State K/V — thin wrappers ───────────────────────────────────────────── */
+
+el_val_t __state_set(el_val_t key, el_val_t value) { return state_set(key, value); }
+el_val_t __state_get(el_val_t key) { return state_get(key); }
+el_val_t __state_del(el_val_t key) { return state_del(key); }
+el_val_t __state_keys(void) { return state_keys(); }
+
+/* ── HTML/URL — thin wrappers ────────────────────────────────────────────── */
+
+el_val_t __html_sanitize(el_val_t input_html, el_val_t allowlist_json) {
+ return el_html_sanitize(input_html, allowlist_json);
+}
+
+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); }
+
+/* ── Engram — thin wrappers ──────────────────────────────────────────────── */
+
+el_val_t __engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
+ return engram_node(content, node_type, salience);
+}
+
+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) {
+ return engram_node_full(content, node_type, label, salience, importance, confidence, tier, tags);
+}
+
+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) {
+ return engram_node_layered(content, node_type, label, salience, certainty, confidence,
+ status, tags, layer_id);
+}
+
+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) {
+ return engram_add_layer(name, priority, suppressible, transparent, injectable);
+}
+
+el_val_t __engram_remove_layer(el_val_t layer_id) { return engram_remove_layer(layer_id); }
+el_val_t __engram_list_layers(void) { return engram_list_layers(); }
+el_val_t __engram_get_node(el_val_t id) { return engram_get_node(id); }
+void __engram_strengthen(el_val_t node_id) { engram_strengthen(node_id); }
+void __engram_forget(el_val_t node_id) { engram_forget(node_id); }
+el_val_t __engram_node_count(void) { return engram_node_count(); }
+
+el_val_t __engram_search(el_val_t query, el_val_t limit) { return engram_search(query, limit); }
+el_val_t __engram_scan_nodes(el_val_t limit, el_val_t offset) { return engram_scan_nodes(limit, offset); }
+
+void __engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
+ engram_connect(from_id, to_id, weight, relation);
+}
+
+el_val_t __engram_edge_between(el_val_t from_id, el_val_t to_id) {
+ return engram_edge_between(from_id, to_id);
+}
+
+el_val_t __engram_neighbors(el_val_t node_id) { return engram_neighbors(node_id); }
+
+el_val_t __engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
+ return engram_neighbors_filtered(node_id, max_depth, direction);
+}
+
+el_val_t __engram_edge_count(void) { return engram_edge_count(); }
+
+el_val_t __engram_activate(el_val_t query, el_val_t depth) { return engram_activate(query, depth); }
+el_val_t __engram_save(el_val_t path) { return engram_save(path); }
+el_val_t __engram_load(el_val_t path) { return engram_load(path); }
+
+el_val_t __engram_get_node_json(el_val_t id) { return engram_get_node_json(id); }
+
+el_val_t __engram_search_json(el_val_t query, el_val_t limit) {
+ return engram_search_json(query, limit);
+}
+
+el_val_t __engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
+ return engram_scan_nodes_json(limit, offset);
+}
+
+el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset) {
+ return engram_scan_nodes_by_type_json(node_type, limit, offset);
+}
+
+el_val_t __engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
+ return engram_neighbors_json(node_id, max_depth, direction);
+}
+
+el_val_t __engram_activate_json(el_val_t query, el_val_t depth) {
+ return engram_activate_json(query, depth);
+}
+
+el_val_t __engram_stats_json(void) { return engram_stats_json(); }
+el_val_t __engram_list_layers_json(void) { return engram_list_layers_json(); }
+
+el_val_t __engram_compile_layered_json(el_val_t intent, el_val_t depth) {
+ return engram_compile_layered_json(intent, depth);
+}
+
+/* ── Cryptographic hashing ────────────────────────────────────────────────── */
+/*
+ * SHA-256 — self-contained implementation (no OpenSSL dependency).
+ * Based on Brad Conte's public-domain reference implementation.
+ */
+
+typedef struct {
+ uint8_t data[64];
+ uint32_t datalen;
+ uint64_t bitlen;
+ uint32_t state[8];
+} _seed_sha256_ctx;
+
+static const uint32_t _seed_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 _SEED_ROTR32(x,n) (((x)>>(n))|((x)<<(32-(n))))
+#define _SEED_CH(x,y,z) (((x)&(y))^(~(x)&(z)))
+#define _SEED_MAJ(x,y,z) (((x)&(y))^((x)&(z))^((y)&(z)))
+#define _SEED_EP0(x) (_SEED_ROTR32(x,2)^_SEED_ROTR32(x,13)^_SEED_ROTR32(x,22))
+#define _SEED_EP1(x) (_SEED_ROTR32(x,6)^_SEED_ROTR32(x,11)^_SEED_ROTR32(x,25))
+#define _SEED_SIG0(x) (_SEED_ROTR32(x,7)^_SEED_ROTR32(x,18)^((x)>>3))
+#define _SEED_SIG1(x) (_SEED_ROTR32(x,17)^_SEED_ROTR32(x,19)^((x)>>10))
+
+static void _seed_sha256_transform(_seed_sha256_ctx* ctx, const uint8_t* data) {
+ uint32_t a,b,c,d,e,f,g,h,t1,t2,m[64];
+ for (int i=0,j=0; i<16; i++,j+=4)
+ m[i]=(uint32_t)(data[j]<<24)|(data[j+1]<<16)|(data[j+2]<<8)|data[j+3];
+ for (int i=16; i<64; i++)
+ m[i]=_SEED_SIG1(m[i-2])+m[i-7]+_SEED_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 (int i=0; i<64; i++) {
+ t1=h+_SEED_EP1(e)+_SEED_CH(e,f,g)+_seed_sha256_k[i]+m[i];
+ t2=_SEED_EP0(a)+_SEED_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 _seed_sha256_init(_seed_sha256_ctx* 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 _seed_sha256_update(_seed_sha256_ctx* ctx, const uint8_t* data, size_t len) {
+ for (size_t i=0; idata[ctx->datalen++] = data[i];
+ if (ctx->datalen==64) { _seed_sha256_transform(ctx,ctx->data); ctx->bitlen+=512; ctx->datalen=0; }
+ }
+}
+
+static void _seed_sha256_final(_seed_sha256_ctx* ctx, uint8_t hash[32]) {
+ uint32_t i=ctx->datalen;
+ ctx->data[i++]=0x80;
+ if (ctx->datalen<56) { while(i<56) ctx->data[i++]=0; }
+ else { while(i<64) ctx->data[i++]=0; _seed_sha256_transform(ctx,ctx->data); memset(ctx->data,0,56); }
+ ctx->bitlen+=ctx->datalen*8;
+ ctx->data[63]=(uint8_t)(ctx->bitlen); ctx->data[62]=(uint8_t)(ctx->bitlen>>8);
+ ctx->data[61]=(uint8_t)(ctx->bitlen>>16); ctx->data[60]=(uint8_t)(ctx->bitlen>>24);
+ ctx->data[59]=(uint8_t)(ctx->bitlen>>32); ctx->data[58]=(uint8_t)(ctx->bitlen>>40);
+ ctx->data[57]=(uint8_t)(ctx->bitlen>>48); ctx->data[56]=(uint8_t)(ctx->bitlen>>56);
+ _seed_sha256_transform(ctx,ctx->data);
+ for (i=0; i<4; i++) {
+ hash[i] =(uint8_t)(ctx->state[0]>>(24-i*8));
+ hash[i+4] =(uint8_t)(ctx->state[1]>>(24-i*8));
+ hash[i+8] =(uint8_t)(ctx->state[2]>>(24-i*8));
+ hash[i+12] =(uint8_t)(ctx->state[3]>>(24-i*8));
+ hash[i+16] =(uint8_t)(ctx->state[4]>>(24-i*8));
+ hash[i+20] =(uint8_t)(ctx->state[5]>>(24-i*8));
+ hash[i+24] =(uint8_t)(ctx->state[6]>>(24-i*8));
+ hash[i+28] =(uint8_t)(ctx->state[7]>>(24-i*8));
+ }
+}
+
+/* ── Manifest reader (shared: macOS + Linux) ─────────────────────────────── */
+/*
+ * Parse the app{} block from a manifest.el file.
+ * Expected format (subset — only app{} block is parsed):
+ *
+ * app {
+ * window_title "My App"
+ * window_width 1200
+ * window_height 800
+ * min_width 600
+ * min_height 400
+ * }
+ *
+ * Returns JSON: {"title":"...","width":N,"height":N,"min_width":N,"min_height":N}
+ * Returns "{}" on parse failure.
+ *
+ * Compiled unconditionally so both EL_TARGET_MACOS and EL_TARGET_LINUX can use
+ * it — the __manifest_read builtin is identical on all native targets.
+ */
+
+static void seed_manifest_skip_ws(const char** p) {
+ while (**p && (unsigned char)**p <= ' ') (*p)++;
+}
+
+/* Read a quoted string literal. Caller must free() the result. */
+static char* seed_manifest_read_string(const char** p) {
+ seed_manifest_skip_ws(p);
+ if (**p != '"') return NULL;
+ (*p)++;
+ const char* start = *p;
+ while (**p && **p != '"') {
+ if (**p == '\\') (*p)++; /* skip escaped char */
+ (*p)++;
+ }
+ size_t len = (size_t)(*p - start);
+ if (**p == '"') (*p)++;
+ char* out = malloc(len + 1);
+ if (!out) return NULL;
+ memcpy(out, start, len);
+ out[len] = '\0';
+ return out;
+}
+
+/* Read an unquoted integer literal. */
+static int64_t seed_manifest_read_int(const char** p) {
+ seed_manifest_skip_ws(p);
+ int64_t result = 0;
+ int neg = 0;
+ if (**p == '-') { neg = 1; (*p)++; }
+ while (**p >= '0' && **p <= '9') {
+ result = result * 10 + (**p - '0');
+ (*p)++;
+ }
+ return neg ? -result : result;
+}
+
+/* Find "app {" block start. Returns pointer past the '{'. */
+static const char* seed_manifest_find_app_block(const char* src) {
+ while (*src) {
+ /* Skip line comments (// ...) */
+ if (src[0] == '/' && src[1] == '/') {
+ while (*src && *src != '\n') src++;
+ continue;
+ }
+ if (strncmp(src, "app", 3) == 0 &&
+ (src[3] == ' ' || src[3] == '\t' || src[3] == '\n' || src[3] == '{')) {
+ src += 3;
+ while (*src && *src != '{') src++;
+ if (*src == '{') return src + 1;
+ return NULL;
+ }
+ src++;
+ }
+ return NULL;
+}
+
+el_val_t __manifest_read(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return seed_wrap_str(seed_strdup("{}"));
+
+ /* Read the file. */
+ FILE* f = fopen(p, "r");
+ if (!f) return seed_wrap_str(seed_strdup("{}"));
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ rewind(f);
+ if (sz <= 0) { fclose(f); return seed_wrap_str(seed_strdup("{}")); }
+ char* src = malloc((size_t)sz + 1);
+ if (!src) { fclose(f); return seed_wrap_str(seed_strdup("{}")); }
+ size_t got = fread(src, 1, (size_t)sz, f);
+ src[got] = '\0';
+ fclose(f);
+
+ const char* app_start = seed_manifest_find_app_block(src);
+ if (!app_start) { free(src); return seed_wrap_str(seed_strdup("{}")); }
+
+ /* Parse key-value pairs inside the app { } block. */
+ char* title = NULL;
+ int64_t width = 1200;
+ int64_t height = 800;
+ int64_t min_w = 600;
+ int64_t min_h = 400;
+
+ const char* cur = app_start;
+ while (*cur && *cur != '}') {
+ /* Skip whitespace and comments. */
+ while (*cur && (unsigned char)*cur <= ' ') cur++;
+ if (*cur == '}') break;
+ if (cur[0] == '/' && cur[1] == '/') {
+ while (*cur && *cur != '\n') cur++;
+ continue;
+ }
+
+ /* Read key token. */
+ const char* key_start = cur;
+ while (*cur && (unsigned char)*cur > ' ' && *cur != '{' && *cur != '}') cur++;
+ size_t key_len = (size_t)(cur - key_start);
+ if (key_len == 0) { cur++; continue; }
+
+ /* Skip whitespace between key and value. */
+ while (*cur == ' ' || *cur == '\t') cur++;
+
+ if (strncmp(key_start, "window_title", key_len) == 0 && key_len == 12) {
+ free(title);
+ title = seed_manifest_read_string(&cur);
+ } else if (strncmp(key_start, "window_width", key_len) == 0 && key_len == 12) {
+ width = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "window_height", key_len) == 0 && key_len == 13) {
+ height = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "min_width", key_len) == 0 && key_len == 9) {
+ min_w = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "min_height", key_len) == 0 && key_len == 10) {
+ min_h = seed_manifest_read_int(&cur);
+ } else {
+ /* Unknown key — skip to end of line. */
+ while (*cur && *cur != '\n' && *cur != '}') cur++;
+ }
+ }
+
+ free(src);
+
+ /* Build JSON result. */
+ const char* t = title ? title : "App";
+ size_t buf_sz = strlen(t) * 6 + 128;
+ char* buf = seed_strbuf(buf_sz);
+ snprintf(buf, buf_sz,
+ "{\"title\":\"%s\",\"width\":%lld,\"height\":%lld,\"min_width\":%lld,\"min_height\":%lld}",
+ t, (long long)width, (long long)height, (long long)min_w, (long long)min_h);
+ free(title);
+ return seed_wrap_str(buf);
+}
+
+/* ── Native widget system — macOS AppKit ─────────────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_MACOS is defined.
+ * They call through to el_appkit.m (Objective-C) which implements the actual
+ * AppKit widget creation and management.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_appkit_* function, return the result.
+ *
+ * Compile requirements:
+ * - Compile el_appkit.m alongside this file (clang -ObjC -framework Cocoa)
+ * - Pass -DEL_TARGET_MACOS to enable this section
+ */
+
+#ifdef EL_TARGET_MACOS
+
+/* Forward declarations — implemented in el_appkit.m */
+extern void el_appkit_init(void);
+extern void el_appkit_run_loop(void);
+extern int64_t el_appkit_window_create(const char* title, int w, int h, int mw, int mh);
+extern void el_appkit_window_show(int64_t handle);
+extern void el_appkit_window_set_title(int64_t handle, const char* title);
+extern int64_t el_appkit_vstack_create(int spacing);
+extern int64_t el_appkit_hstack_create(int spacing);
+extern int64_t el_appkit_zstack_create(void);
+extern int64_t el_appkit_scroll_create(void);
+extern int64_t el_appkit_label_create(const char* text);
+extern int64_t el_appkit_button_create(const char* label);
+extern int64_t el_appkit_text_field_create(const char* placeholder);
+extern int64_t el_appkit_text_area_create(const char* placeholder);
+extern int64_t el_appkit_image_create(const char* path_or_name);
+extern void el_appkit_widget_set_text(int64_t handle, const char* text);
+extern const char* el_appkit_widget_get_text(int64_t handle);
+extern void el_appkit_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_appkit_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_appkit_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_appkit_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_appkit_widget_set_width(int64_t h, int width);
+extern void el_appkit_widget_set_height(int64_t h, int height);
+extern void el_appkit_widget_set_flex(int64_t h, int flex);
+extern void el_appkit_widget_set_corner_radius(int64_t h, int radius);
+extern void el_appkit_widget_set_disabled(int64_t h, int disabled);
+extern void el_appkit_widget_set_hidden(int64_t h, int hidden);
+extern void el_appkit_widget_add_child(int64_t parent, int64_t child);
+extern void el_appkit_widget_remove_child(int64_t parent, int64_t child);
+extern void el_appkit_widget_destroy(int64_t handle);
+extern void el_appkit_widget_on_click(int64_t h, const char* fn_name);
+extern void el_appkit_widget_on_change(int64_t h, const char* fn_name);
+extern void el_appkit_widget_on_submit(int64_t h, const char* fn_name);
+
+/* ── Native widget builtins ──────────────────────────────────────────────── */
+
+void __native_init(void) { el_appkit_init(); }
+void __native_run_loop(void) { el_appkit_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_appkit_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_appkit_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_appkit_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_appkit_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_appkit_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_appkit_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_appkit_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_appkit_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_appkit_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_appkit_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_appkit_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_appkit_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_appkit_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_appkit_widget_get_text((int64_t)h);
+ if (!s) return seed_wrap_str(seed_strdup(""));
+ /* el_appkit returns strdup'd string — wrap it (arena won't track it, but
+ * caller must treat return as short-lived or copy it). */
+ return EL_STR(s);
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_appkit_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_appkit_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_appkit_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_appkit_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_appkit_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_appkit_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_appkit_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_appkit_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_appkit_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_appkit_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_appkit_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_appkit_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_appkit_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_MACOS */
+
+/* ── Native widget system — Linux GTK4 ──────────────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_LINUX is defined.
+ * They call through to el_gtk4.c which implements the GTK4 widget layer.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_gtk4_* function, return the result.
+ *
+ * Compile requirements:
+ * - Compile el_gtk4.c alongside this file
+ * gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) el_gtk4.c -c ...
+ * - Pass -DEL_TARGET_LINUX to enable this section
+ * - Link with $(pkg-config --libs gtk4) -ldl
+ *
+ * __manifest_read is shared with the macOS path (implemented above in the
+ * seed_manifest_* helpers) and compiled for both targets unconditionally.
+ */
+
+#ifdef EL_TARGET_LINUX
+
+/* Forward declarations — implemented in el_gtk4.c */
+extern void el_gtk4_init(void);
+extern void el_gtk4_run_loop(void);
+extern int64_t el_gtk4_window_create(const char* title, int w, int h,
+ int mw, int mh);
+extern void el_gtk4_window_show(int64_t handle);
+extern void el_gtk4_window_set_title(int64_t handle, const char* title);
+extern int64_t el_gtk4_vstack_create(int spacing);
+extern int64_t el_gtk4_hstack_create(int spacing);
+extern int64_t el_gtk4_zstack_create(void);
+extern int64_t el_gtk4_scroll_create(void);
+extern int64_t el_gtk4_label_create(const char* text);
+extern int64_t el_gtk4_button_create(const char* label);
+extern int64_t el_gtk4_text_field_create(const char* placeholder);
+extern int64_t el_gtk4_text_area_create(const char* placeholder);
+extern int64_t el_gtk4_image_create(const char* path_or_name);
+extern void el_gtk4_widget_set_text(int64_t handle, const char* text);
+extern const char* el_gtk4_widget_get_text(int64_t handle);
+extern void el_gtk4_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_gtk4_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_gtk4_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_gtk4_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_gtk4_widget_set_width(int64_t h, int width);
+extern void el_gtk4_widget_set_height(int64_t h, int height);
+extern void el_gtk4_widget_set_flex(int64_t h, int flex);
+extern void el_gtk4_widget_set_corner_radius(int64_t h, int radius);
+extern void el_gtk4_widget_set_disabled(int64_t h, int disabled);
+extern void el_gtk4_widget_set_hidden(int64_t h, int hidden);
+extern void el_gtk4_widget_add_child(int64_t parent, int64_t child);
+extern void el_gtk4_widget_remove_child(int64_t parent, int64_t child);
+extern void el_gtk4_widget_destroy(int64_t handle);
+extern void el_gtk4_widget_on_click(int64_t h, const char* fn_name);
+extern void el_gtk4_widget_on_change(int64_t h, const char* fn_name);
+extern void el_gtk4_widget_on_submit(int64_t h, const char* fn_name);
+
+void __native_init(void) { el_gtk4_init(); }
+void __native_run_loop(void) { el_gtk4_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_gtk4_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_gtk4_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_gtk4_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_gtk4_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_gtk4_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_gtk4_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_gtk4_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_gtk4_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_gtk4_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_gtk4_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_gtk4_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_gtk4_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_gtk4_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_gtk4_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s); /* el_gtk4 returns strdup'd string */
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_gtk4_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_gtk4_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_gtk4_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_gtk4_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_gtk4_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_gtk4_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_gtk4_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_gtk4_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_gtk4_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_gtk4_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_gtk4_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_gtk4_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_gtk4_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_LINUX */
+
+/* ── Native widget system — SDL2 (embedded / Pi) ─────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_SDL2 is defined.
+ * They call through to el_sdl2.c which implements a retained-mode pixel
+ * widget layer on top of SDL2 + SDL_ttf + SDL_image.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_sdl2_* function, return the result.
+ *
+ * Compile requirements:
+ * gcc -DEL_TARGET_SDL2 $(sdl2-config --cflags) el_sdl2.c -c ...
+ * Pass -DEL_TARGET_SDL2 to enable this section.
+ * Link:
+ * $(sdl2-config --libs) -lSDL2_ttf -lSDL2_image -ldl
+ *
+ * __manifest_read is shared with the macOS/Linux paths (implemented above).
+ */
+
+#ifdef EL_TARGET_SDL2
+
+/* Forward declarations — implemented in el_sdl2.c */
+extern void el_sdl2_init(void);
+extern void el_sdl2_run_loop(void);
+extern int64_t el_sdl2_window_create(const char* title, int w, int h,
+ int mw, int mh);
+extern void el_sdl2_window_show(int64_t handle);
+extern void el_sdl2_window_set_title(int64_t handle, const char* title);
+extern int64_t el_sdl2_vstack_create(int spacing);
+extern int64_t el_sdl2_hstack_create(int spacing);
+extern int64_t el_sdl2_zstack_create(void);
+extern int64_t el_sdl2_scroll_create(void);
+extern int64_t el_sdl2_label_create(const char* text);
+extern int64_t el_sdl2_button_create(const char* label);
+extern int64_t el_sdl2_text_field_create(const char* placeholder);
+extern int64_t el_sdl2_text_area_create(const char* placeholder);
+extern int64_t el_sdl2_image_create(const char* path_or_name);
+extern void el_sdl2_widget_set_text(int64_t handle, const char* text);
+extern const char* el_sdl2_widget_get_text(int64_t handle);
+extern void el_sdl2_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_sdl2_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_sdl2_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_sdl2_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_sdl2_widget_set_width(int64_t h, int width);
+extern void el_sdl2_widget_set_height(int64_t h, int height);
+extern void el_sdl2_widget_set_flex(int64_t h, int flex);
+extern void el_sdl2_widget_set_corner_radius(int64_t h, int radius);
+extern void el_sdl2_widget_set_disabled(int64_t h, int disabled);
+extern void el_sdl2_widget_set_hidden(int64_t h, int hidden);
+extern void el_sdl2_widget_add_child(int64_t parent, int64_t child);
+extern void el_sdl2_widget_remove_child(int64_t parent, int64_t child);
+extern void el_sdl2_widget_destroy(int64_t handle);
+extern void el_sdl2_widget_on_click(int64_t h, const char* fn_name);
+extern void el_sdl2_widget_on_change(int64_t h, const char* fn_name);
+extern void el_sdl2_widget_on_submit(int64_t h, const char* fn_name);
+
+void __native_init(void) { el_sdl2_init(); }
+void __native_run_loop(void) { el_sdl2_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_sdl2_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_sdl2_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_sdl2_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_sdl2_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_sdl2_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_sdl2_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_sdl2_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_sdl2_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_sdl2_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_sdl2_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_sdl2_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_sdl2_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_sdl2_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_sdl2_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s);
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_sdl2_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_sdl2_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_sdl2_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_sdl2_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_sdl2_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_sdl2_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_sdl2_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_sdl2_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_sdl2_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_sdl2_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_sdl2_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_sdl2_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_sdl2_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_SDL2 */
+
+/* ── EL_TARGET_LVGL — LVGL v9 embedded backend ───────────────────────────────
+ *
+ * These builtins are compiled in only when EL_TARGET_LVGL is defined.
+ * Each wrapper translates el_val_t arguments (opaque int64_t) into the typed
+ * parameters that el_lvgl_* expects, then calls through.
+ *
+ * Build:
+ * gcc -DEL_TARGET_LVGL -I./lvgl el_lvgl.c el_seed.c -c ...
+ * # Link with lvgl.a. For bare-metal add -DEL_LVGL_NO_DLSYM.
+ *
+ * __manifest_read is shared — defined unconditionally above.
+ */
+
+#ifdef EL_TARGET_LVGL
+
+extern void el_lvgl_init(void);
+extern void el_lvgl_run_loop(void);
+extern int64_t el_lvgl_window_create(const char *title, int w, int h,
+ int mw, int mh);
+extern void el_lvgl_window_show(int64_t handle);
+extern void el_lvgl_window_set_title(int64_t handle, const char *title);
+extern int64_t el_lvgl_vstack_create(int spacing);
+extern int64_t el_lvgl_hstack_create(int spacing);
+extern int64_t el_lvgl_zstack_create(void);
+extern int64_t el_lvgl_scroll_create(void);
+extern int64_t el_lvgl_label_create(const char *text);
+extern int64_t el_lvgl_button_create(const char *label);
+extern int64_t el_lvgl_text_field_create(const char *placeholder);
+extern int64_t el_lvgl_text_area_create(const char *placeholder);
+extern int64_t el_lvgl_image_create(const char *path_or_name);
+extern void el_lvgl_widget_set_text(int64_t handle, const char *text);
+extern const char *el_lvgl_widget_get_text(int64_t handle);
+extern void el_lvgl_widget_set_color(int64_t h, float r, float g,
+ float b, float a);
+extern void el_lvgl_widget_set_bg_color(int64_t h, float r, float g,
+ float b, float a);
+extern void el_lvgl_widget_set_font(int64_t h, const char *family,
+ int size, int bold);
+extern void el_lvgl_widget_set_padding(int64_t h, int top, int right,
+ int bottom, int left);
+extern void el_lvgl_widget_set_width(int64_t h, int width);
+extern void el_lvgl_widget_set_height(int64_t h, int height);
+extern void el_lvgl_widget_set_flex(int64_t h, int flex);
+extern void el_lvgl_widget_set_corner_radius(int64_t h, int radius);
+extern void el_lvgl_widget_set_disabled(int64_t h, int disabled);
+extern void el_lvgl_widget_set_hidden(int64_t h, int hidden);
+extern void el_lvgl_widget_add_child(int64_t parent, int64_t child);
+extern void el_lvgl_widget_remove_child(int64_t parent, int64_t child);
+extern void el_lvgl_widget_destroy(int64_t handle);
+extern void el_lvgl_widget_on_click(int64_t h, const char *fn_name);
+extern void el_lvgl_widget_on_change(int64_t h, const char *fn_name);
+extern void el_lvgl_widget_on_submit(int64_t h, const char *fn_name);
+
+void __native_init(void) { el_lvgl_init(); }
+void __native_run_loop(void) { el_lvgl_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_lvgl_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_lvgl_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_lvgl_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_lvgl_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_lvgl_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_lvgl_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_lvgl_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_lvgl_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_lvgl_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_lvgl_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_lvgl_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_lvgl_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_lvgl_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char *s = el_lvgl_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s); /* pointer into LVGL internal storage — valid until next LVGL op */
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_lvgl_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_lvgl_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_lvgl_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_lvgl_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_lvgl_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_lvgl_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_lvgl_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_lvgl_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_lvgl_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_lvgl_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_lvgl_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_lvgl_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_lvgl_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_LVGL */
+
+/* ── Cryptographic hashing ────────────────────────────────────────────────── */
+
+el_val_t __sha256_hex(el_val_t sv) {
+ const char* s = EL_CSTR(sv);
+ if (!s) s = "";
+ _seed_sha256_ctx ctx;
+ _seed_sha256_init(&ctx);
+ _seed_sha256_update(&ctx, (const uint8_t*)s, strlen(s));
+ uint8_t digest[32];
+ _seed_sha256_final(&ctx, digest);
+ static const char hex[] = "0123456789abcdef";
+ char* out = malloc(65);
+ if (!out) return EL_STR("");
+ for (int i=0; i<32; i++) {
+ out[i*2] = hex[(digest[i]>>4)&0xf];
+ out[i*2+1] = hex[digest[i]&0xf];
+ }
+ out[64] = '\0';
+ return EL_STR(out);
+}
diff --git a/ui/examples/native-hello-android/app/src/main/jni/native_hello.c b/ui/examples/native-hello-android/app/src/main/jni/native_hello.c
new file mode 100644
index 0000000..050d56a
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/jni/native_hello.c
@@ -0,0 +1,123 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data);
+el_val_t on_counter_click(el_val_t widget, el_val_t data);
+el_val_t on_name_change(el_val_t widget, el_val_t data);
+el_val_t app_build(el_val_t window);
+
+el_val_t g_window;
+el_val_t g_label;
+el_val_t g_input;
+el_val_t g_button;
+el_val_t g_counter;
+el_val_t g_counter_lbl;
+el_val_t manifest_env;
+el_val_t manifest_path;
+el_val_t win;
+el_val_t g_window;
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data) {
+ el_val_t name = widget_get_text(g_input);
+ el_val_t greeting = ({ el_val_t _if_result_1 = 0; if ((str_len(name) > 0)) { _if_result_1 = (el_str_concat(el_str_concat(EL_STR("Hello, "), name), EL_STR("!"))); } else { _if_result_1 = (EL_STR("Hello, World!")); } _if_result_1; });
+ widget_set_text(g_label, greeting);
+ return 0;
+}
+
+el_val_t on_counter_click(el_val_t widget, el_val_t data) {
+ el_val_t g_counter = (g_counter + 1);
+ widget_set_text(g_counter_lbl, el_str_concat(EL_STR("Clicks: "), int_to_str(g_counter)));
+ return 0;
+}
+
+el_val_t on_name_change(el_val_t widget, el_val_t data) {
+ el_val_t has_text = (str_len(data) > 0);
+ widget_set_disabled(g_button, !has_text);
+ return 0;
+}
+
+el_val_t app_build(el_val_t window) {
+ el_val_t root = vstack(0);
+ widget_set_padding_all(root, 24);
+ widget_set_bg_color_hex(root, EL_STR("#0f172a"));
+ widget_set_flex(root, 1);
+ el_val_t title = label(EL_STR("el-native \xe2\x80\x94 native widget demo"));
+ style_label_heading(title);
+ widget_add_child(root, title);
+ el_val_t gap1 = label(EL_STR(""));
+ widget_set_height(gap1, 16);
+ widget_add_child(root, gap1);
+ el_val_t subtitle = label(EL_STR("AppKit controls from el code, no ObjC in the app layer."));
+ style_label_muted(subtitle);
+ widget_add_child(root, subtitle);
+ el_val_t gap2 = label(EL_STR(""));
+ widget_set_height(gap2, 24);
+ widget_add_child(root, gap2);
+ el_val_t input_row = hstack(8);
+ el_val_t name_label = label(EL_STR("Name:"));
+ style_label_body(name_label);
+ widget_set_width(name_label, 60);
+ el_val_t input = text_field(EL_STR("Enter your name\xe2\x80\xa6"));
+ style_label_body(input);
+ widget_set_flex(input, 1);
+ widget_on_change(input, EL_STR("on_name_change"));
+ el_val_t g_input = input;
+ el_val_t greet_btn = button(EL_STR("Greet"));
+ style_button_primary(greet_btn);
+ widget_set_disabled(greet_btn, 1);
+ widget_on_click(greet_btn, EL_STR("on_greet_click"));
+ el_val_t g_button = greet_btn;
+ widget_add_child(input_row, name_label);
+ widget_add_child(input_row, input);
+ widget_add_child(input_row, greet_btn);
+ widget_add_child(root, input_row);
+ el_val_t gap3 = label(EL_STR(""));
+ widget_set_height(gap3, 12);
+ widget_add_child(root, gap3);
+ el_val_t greeting = label(EL_STR("Waiting for name\xe2\x80\xa6"));
+ style_label_body(greeting);
+ widget_set_color_hex(greeting, EL_STR("#60a5fa"));
+ widget_set_font(greeting, EL_STR("system"), 16, 1);
+ el_val_t g_label = greeting;
+ widget_add_child(root, greeting);
+ el_val_t gap4 = label(EL_STR(""));
+ widget_set_height(gap4, 24);
+ widget_add_child(root, gap4);
+ el_val_t counter_row = hstack(12);
+ el_val_t counter_lbl = label(EL_STR("Clicks: 0"));
+ style_label_body(counter_lbl);
+ el_val_t g_counter_lbl = counter_lbl;
+ el_val_t counter_btn = button(EL_STR("Click me"));
+ style_button_primary(counter_btn);
+ widget_on_click(counter_btn, EL_STR("on_counter_click"));
+ widget_add_child(counter_row, counter_lbl);
+ widget_add_child(counter_row, counter_btn);
+ widget_add_child(root, counter_row);
+ widget_add_child(window, root);
+ return 0;
+}
+
+int main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ g_window = (-1);
+ g_label = (-1);
+ g_input = (-1);
+ g_button = (-1);
+ g_counter = 0;
+ g_counter_lbl = (-1);
+ native_init();
+ manifest_env = env(EL_STR("EL_MANIFEST"));
+ manifest_path = ({ el_val_t _if_result_2 = 0; if ((str_len(manifest_env) > 0)) { _if_result_2 = (manifest_env); } else { _if_result_2 = (EL_STR("manifest.el")); } _if_result_2; });
+ win = window_from_manifest(manifest_path);
+ g_window = win;
+ if (win < 0) {
+ println(EL_STR("Error: failed to create window. Is EL_TARGET_MACOS defined?"));
+ exit_program(1);
+ }
+ app_build(win);
+ window_show(win);
+ native_run_loop();
+ return 0;
+}
+
diff --git a/ui/examples/native-hello-android/app/src/main/res/layout/activity_main.xml b/ui/examples/native-hello-android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..c2ac18f
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/ui/examples/native-hello-android/app/src/main/res/values/strings.xml b/ui/examples/native-hello-android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..b0cc308
--- /dev/null
+++ b/ui/examples/native-hello-android/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ el-native
+
diff --git a/ui/examples/native-hello-android/build.gradle b/ui/examples/native-hello-android/build.gradle
new file mode 100644
index 0000000..3143e8a
--- /dev/null
+++ b/ui/examples/native-hello-android/build.gradle
@@ -0,0 +1,4 @@
+// Top-level build file — configuration for all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.2.2' apply false
+}
diff --git a/ui/examples/native-hello-android/build.sh b/ui/examples/native-hello-android/build.sh
new file mode 100755
index 0000000..4428bbd
--- /dev/null
+++ b/ui/examples/native-hello-android/build.sh
@@ -0,0 +1,83 @@
+#!/usr/bin/env bash
+# build.sh — Build native-hello-android.
+#
+# Usage:
+# ./build.sh # regenerate C sources + ./gradlew assembleDebug
+# ./build.sh gen # regenerate C sources only
+# ./build.sh gradle # run ./gradlew assembleDebug (skip elc step)
+# ./build.sh clean # clean Gradle build outputs
+#
+# Requirements:
+# - Android SDK with NDK and CMake installed
+# - local.properties present (copy from local.properties.template and edit sdk.dir)
+# - Java 11+ on PATH (for Gradle)
+# - elc at ../../../lang/dist/platform/elc
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+EL_LANG_ROOT="${SCRIPT_DIR}/../../../lang"
+EL_UI_ROOT="${SCRIPT_DIR}/../.."
+EL_RUNTIME="${EL_LANG_ROOT}/el-compiler/runtime"
+EL_NATIVE_VESSEL="${EL_UI_ROOT}/vessels/el-native/src/main.el"
+EL_APP_ENTRY="${EL_UI_ROOT}/examples/native-hello/src/main.el"
+EL_MANIFEST="${EL_UI_ROOT}/examples/native-hello/manifest.el"
+JNI_DIR="${SCRIPT_DIR}/app/src/main/jni"
+
+# Locate elc.
+if command -v elc &>/dev/null; then
+ ELC="elc"
+elif [ -x "${EL_LANG_ROOT}/dist/platform/elc" ]; then
+ ELC="${EL_LANG_ROOT}/dist/platform/elc"
+else
+ echo "Error: elc not found. Add it to PATH or place it at:"
+ echo " ${EL_LANG_ROOT}/dist/platform/elc"
+ exit 1
+fi
+
+gen() {
+ echo "==> Generating native_hello.c..."
+ EL_MANIFEST="${EL_MANIFEST}" \
+ "${ELC}" "${EL_APP_ENTRY}" \
+ > "${JNI_DIR}/native_hello.c"
+
+ echo "==> Generating el_native_vessel.c..."
+ "${ELC}" "${EL_NATIVE_VESSEL}" \
+ > "${JNI_DIR}/el_native_vessel.c"
+ # Rename the vessel's main() to avoid symbol collision with native_hello.c's main().
+ # The Android JNI entry point (nativeMain) calls native_hello.c's main() explicitly.
+ sed -i '' 's/^int main(int _argc, char\*\* _argv) {/int el_vessel_main(int _argc, char** _argv) {/' \
+ "${JNI_DIR}/el_native_vessel.c" 2>/dev/null || \
+ sed -i 's/^int main(int _argc, char\*\* _argv) {/int el_vessel_main(int _argc, char** _argv) {/' \
+ "${JNI_DIR}/el_native_vessel.c"
+
+ echo "==> C source generation complete."
+}
+
+gradle_build() {
+ echo "==> Running ./gradlew assembleDebug..."
+ cd "${SCRIPT_DIR}"
+ ./gradlew assembleDebug
+
+ APK_PATH="${SCRIPT_DIR}/app/build/outputs/apk/debug/app-debug.apk"
+ if [ -f "${APK_PATH}" ]; then
+ echo "==> Build complete."
+ echo " APK: ${APK_PATH}"
+ else
+ echo "==> Build complete (APK path may differ — check app/build/outputs/apk/)."
+ fi
+}
+
+clean() {
+ echo "==> Cleaning..."
+ cd "${SCRIPT_DIR}"
+ ./gradlew clean
+ echo "==> Clean complete."
+}
+
+case "${1:-all}" in
+ gen) gen ;;
+ gradle) gradle_build ;;
+ clean) clean ;;
+ all|*) gen && gradle_build ;;
+esac
diff --git a/ui/examples/native-hello-android/gradle.properties b/ui/examples/native-hello-android/gradle.properties
new file mode 100644
index 0000000..150f321
--- /dev/null
+++ b/ui/examples/native-hello-android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.enableJetifier=false
diff --git a/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.jar b/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..033e24c
Binary files /dev/null and b/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.properties b/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a363877
--- /dev/null
+++ b/ui/examples/native-hello-android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+networkTimeout=10000
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/ui/examples/native-hello-android/gradlew b/ui/examples/native-hello-android/gradlew
new file mode 100755
index 0000000..ba00a16
--- /dev/null
+++ b/ui/examples/native-hello-android/gradlew
@@ -0,0 +1,122 @@
+#!/bin/sh
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# Gradle start up script for POSIX generated by "gradle init".
+#
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ # Make relative path absolute, based on the path of the symlink:
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ ;;
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
+
+exec "$JAVACMD" "$@"
diff --git a/ui/examples/native-hello-android/local.properties.template b/ui/examples/native-hello-android/local.properties.template
new file mode 100644
index 0000000..b9f1e6d
--- /dev/null
+++ b/ui/examples/native-hello-android/local.properties.template
@@ -0,0 +1,7 @@
+# local.properties — machine-local SDK/NDK paths. DO NOT commit. Copy to local.properties.
+#
+# Generate: Android Studio creates this automatically, or set manually:
+# sdk.dir=/Users//Library/Android/sdk
+# ndk.dir=/Users//Library/Android/sdk/ndk/
+
+sdk.dir=/Users/will/Library/Android/sdk
diff --git a/ui/examples/native-hello-android/settings.gradle b/ui/examples/native-hello-android/settings.gradle
new file mode 100644
index 0000000..d9f3ff7
--- /dev/null
+++ b/ui/examples/native-hello-android/settings.gradle
@@ -0,0 +1,17 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "native-hello-android"
+include ':app'
diff --git a/ui/examples/native-hello-ios/.gitignore b/ui/examples/native-hello-ios/.gitignore
new file mode 100644
index 0000000..22a50dd
--- /dev/null
+++ b/ui/examples/native-hello-ios/.gitignore
@@ -0,0 +1,4 @@
+build/
+*.xcuserstate
+xcuserdata/
+NativeHello.xcodeproj/xcuserdata/
diff --git a/ui/examples/native-hello-ios/NativeHello.xcodeproj/project.pbxproj b/ui/examples/native-hello-ios/NativeHello.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..d089ff0
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello.xcodeproj/project.pbxproj
@@ -0,0 +1,377 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 4C6D9832BE495DD69A8573D9 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 8FFDE02477855A55BC5F02CE /* main.m */; };
+ 396FD073065B58F39358D798 /* el_uikit.m in Sources */ = {isa = PBXBuildFile; fileRef = 606F96EC1EDC54CD99E7D722 /* el_uikit.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; };
+ 92BBC3E2AA055FCE840BF779 /* el_seed.c in Sources */ = {isa = PBXBuildFile; fileRef = 618FBE18EAA850D9B493A20F /* el_seed.c */; };
+ 136C7763096C5037A69077AD /* el_runtime.c in Sources */ = {isa = PBXBuildFile; fileRef = A8CCF8316D83597CBB332064 /* el_runtime.c */; };
+ 25136DF2A63F507EA5C7EF99 /* el_native_vessel.c in Sources */ = {isa = PBXBuildFile; fileRef = 3373ABFFE7FB5EFA9343AD02 /* el_native_vessel.c */; };
+ C05D581DBD4E52F6B7F7303E /* native_hello.c in Sources */ = {isa = PBXBuildFile; fileRef = EDA408C749EB5F3CA1E5EEC2 /* native_hello.c */; };
+ C3D9A9987F0050EB859D9218 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FF42D2E0FA0655149E573F7C /* UIKit.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 8FFDE02477855A55BC5F02CE /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; };
+ 606F96EC1EDC54CD99E7D722 /* el_uikit.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = el_uikit.m; sourceTree = ""; };
+ 618FBE18EAA850D9B493A20F /* el_seed.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = el_seed.c; sourceTree = ""; };
+ A8CCF8316D83597CBB332064 /* el_runtime.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = el_runtime.c; sourceTree = ""; };
+ 530AA788858357FBA923217E /* el_runtime.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = el_runtime.h; sourceTree = ""; };
+ E1A7B02609E95D1686523B80 /* el_native_target.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = el_native_target.h; sourceTree = ""; };
+ 3373ABFFE7FB5EFA9343AD02 /* el_native_vessel.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = el_native_vessel.c; sourceTree = ""; };
+ EDA408C749EB5F3CA1E5EEC2 /* native_hello.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = native_hello.c; sourceTree = ""; };
+ 08B6475D8C7952F48EF46B10 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ FF42D2E0FA0655149E573F7C /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
+ BDC23A5F3FCD5D1098144064 /* NativeHello.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NativeHello.app; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 5673C72A848C5FFF94E7CF8F /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ C3D9A9987F0050EB859D9218 /* UIKit.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 05039DD2316A5B41A35E094E /* root */ = {
+ isa = PBXGroup;
+ children = (
+ EBC5644CBC315AFDA6A6A59E /* NativeHello */,
+ D1E4BC2FC3925E6FAFCD38F6 /* Frameworks */,
+ 177F2C27230B527E80CEAED5 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 177F2C27230B527E80CEAED5 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ BDC23A5F3FCD5D1098144064 /* NativeHello.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ EBC5644CBC315AFDA6A6A59E /* NativeHello */ = {
+ isa = PBXGroup;
+ children = (
+ 8FFDE02477855A55BC5F02CE /* main.m */,
+ 606F96EC1EDC54CD99E7D722 /* el_uikit.m */,
+ 618FBE18EAA850D9B493A20F /* el_seed.c */,
+ A8CCF8316D83597CBB332064 /* el_runtime.c */,
+ 530AA788858357FBA923217E /* el_runtime.h */,
+ E1A7B02609E95D1686523B80 /* el_native_target.h */,
+ 3373ABFFE7FB5EFA9343AD02 /* el_native_vessel.c */,
+ EDA408C749EB5F3CA1E5EEC2 /* native_hello.c */,
+ 08B6475D8C7952F48EF46B10 /* Info.plist */,
+ );
+ path = NativeHello;
+ sourceTree = "";
+ };
+ D1E4BC2FC3925E6FAFCD38F6 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ FF42D2E0FA0655149E573F7C /* UIKit.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ F1275C1CDFD05A40B9D6C717 /* NativeHello */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 07AFC970D6B05512B39EDE6A /* Build configuration list for PBXNativeTarget "NativeHello" */;
+ buildPhases = (
+ DF29C8DEE5CB5F1C83967F1F /* Sources */,
+ 5673C72A848C5FFF94E7CF8F /* Frameworks */,
+ D6D57F9604C45AAEA747645E /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = NativeHello;
+ productName = NativeHello;
+ productReference = BDC23A5F3FCD5D1098144064 /* NativeHello.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ D8A823993D3254C7B6541F91 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ F1275C1CDFD05A40B9D6C717 = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = 3DAB235BBB945E0CBB3EF554 /* Build configuration list for PBXProject "NativeHello" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 05039DD2316A5B41A35E094E;
+ productRefGroup = 177F2C27230B527E80CEAED5 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ F1275C1CDFD05A40B9D6C717 /* NativeHello */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ D6D57F9604C45AAEA747645E /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ DF29C8DEE5CB5F1C83967F1F /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 4C6D9832BE495DD69A8573D9 /* main.m in Sources */,
+ 396FD073065B58F39358D798 /* el_uikit.m in Sources */,
+ 92BBC3E2AA055FCE840BF779 /* el_seed.c in Sources */,
+ 136C7763096C5037A69077AD /* el_runtime.c in Sources */,
+ 25136DF2A63F507EA5C7EF99 /* el_native_vessel.c in Sources */,
+ C05D581DBD4E52F6B7F7303E /* native_hello.c in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 071FC6E313815E66BB76FC93 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = NO;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_EXCEPTION = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ };
+ name = Debug;
+ };
+ 64E467FC48E757C0BDE1DFF6 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = NO;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_EXCEPTION = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 5DAFA921569F54D081B65492 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "EL_TARGET_IOS=1",
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ HEADER_SEARCH_PATHS = (
+ "$(SRCROOT)/NativeHello",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = NativeHello/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_CFLAGS = (
+ "-DEL_TARGET_IOS",
+ "$(inherited)",
+ );
+ OTHER_LDFLAGS = (
+ "-ldl",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = ai.neurontechnologies.el.nativehello;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 81898B3044815511B8C31D64 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "EL_TARGET_IOS=1",
+ "$(inherited)",
+ );
+ HEADER_SEARCH_PATHS = (
+ "$(SRCROOT)/NativeHello",
+ "$(inherited)",
+ );
+ INFOPLIST_FILE = NativeHello/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ OTHER_CFLAGS = (
+ "-DEL_TARGET_IOS",
+ "$(inherited)",
+ );
+ OTHER_LDFLAGS = (
+ "-ldl",
+ "$(inherited)",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = ai.neurontechnologies.el.nativehello;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 3DAB235BBB945E0CBB3EF554 /* Build configuration list for PBXProject "NativeHello" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 071FC6E313815E66BB76FC93 /* Debug */,
+ 64E467FC48E757C0BDE1DFF6 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 07AFC970D6B05512B39EDE6A /* Build configuration list for PBXNativeTarget "NativeHello" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5DAFA921569F54D081B65492 /* Debug */,
+ 81898B3044815511B8C31D64 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+ };
+ rootObject = D8A823993D3254C7B6541F91 /* Project object */;
+}
diff --git a/ui/examples/native-hello-ios/NativeHello.xcodeproj/xcshareddata/xcschemes/NativeHello.xcscheme b/ui/examples/native-hello-ios/NativeHello.xcodeproj/xcshareddata/xcschemes/NativeHello.xcscheme
new file mode 100644
index 0000000..6eefb67
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello.xcodeproj/xcshareddata/xcschemes/NativeHello.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/examples/native-hello-ios/NativeHello/Info.plist b/ui/examples/native-hello-ios/NativeHello/Info.plist
new file mode 100644
index 0000000..8ab6a41
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+
+ armv7
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ui/examples/native-hello-ios/NativeHello/el_native_target.h b/ui/examples/native-hello-ios/NativeHello/el_native_target.h
new file mode 100644
index 0000000..dc1edcb
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_native_target.h
@@ -0,0 +1,573 @@
+/*
+ * el_native_target.h — Native widget declarations for el programs targeting
+ * native desktop UI (AppKit / GTK4 / Win32).
+ *
+ * This header is designed to be included AFTER el_runtime.h without conflict:
+ * - It does NOT redefine el_to_float, el_from_float, or any el_runtime.h
+ * static inlines.
+ * - It does NOT redeclare __println, __print, or other functions whose
+ * return types differ between el_seed.h and el_runtime.h.
+ * - It adds: native widget builtins + float arithmetic helpers that the
+ * current el_runtime.h omits but elc still emits calls to.
+ *
+ * Usage:
+ * Inject via -include at compile time, OR #include it after el_runtime.h.
+ *
+ * clang -DEL_TARGET_MACOS -include el_native_target.h -c my_app.c ...
+ */
+
+#pragma once
+
+#include
+#include
+
+/* el_val_t must already be defined by el_runtime.h or el_seed.h. */
+#ifndef EL_VAL_T_DEFINED
+typedef int64_t el_val_t;
+#endif
+
+/* ── Float arithmetic helpers ───────────────────────────────────────────────
+ * elc emits calls to float_div / float_mul etc. for Float-typed expressions.
+ * These were in el_runtime.c through v1.0.0-20260501 but are missing from the
+ * current el_runtime.h. Redeclared here as static inline to avoid link deps.
+ * Only defined if not already declared (old runtimes that still have them). */
+
+#ifndef EL_FLOAT_OPS_DEFINED
+#define EL_FLOAT_OPS_DEFINED
+
+/* el_to_float / el_from_float — bit-cast between el_val_t and double.
+ * Defined as static inline in both el_runtime.h and el_seed.h; we do NOT
+ * redefine them here. We rely on one of those headers being included first. */
+
+static inline el_val_t float_div(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b;
+ ur.d = (ub.d != 0.0) ? (ua.d / ub.d) : 0.0;
+ return ur.i;
+}
+
+static inline el_val_t float_mul(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d * ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_add(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d + ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_sub(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d - ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_lt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d < ub.d);
+}
+
+static inline el_val_t float_gt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d > ub.d);
+}
+
+static inline el_val_t float_lte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d <= ub.d);
+}
+
+static inline el_val_t float_gte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d >= ub.d);
+}
+
+static inline el_val_t float_eq(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d == ub.d);
+}
+
+#endif /* EL_FLOAT_OPS_DEFINED */
+
+/* ── Native widget system (macOS AppKit) ────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_MACOS and linked with el_appkit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid. */
+
+#ifdef EL_TARGET_MACOS
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_MACOS */
+
+/* ── Native widget system (Linux GTK4) ──────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_LINUX and linked with el_gtk4.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * All functions have the same signatures as EL_TARGET_MACOS above. */
+
+#ifdef EL_TARGET_LINUX
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as EL_TARGET_MACOS */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LINUX */
+
+/* ── Native widget system (Windows Win32) ───────────────────────────────────
+ * Available when compiled with -DEL_TARGET_WIN32 and linked with el_win32.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * Link: el_win32.obj comctl32.lib user32.lib gdi32.lib */
+
+#ifdef EL_TARGET_WIN32
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_WIN32 */
+
+/* ── Native widget system (iOS UIKit) ───────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_IOS and linked with el_uikit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * iOS lifecycle note: UIApplicationMain never returns. The el program must
+ * store its UI-build logic in a void(*)(void) function pointer, assign it to
+ * el_main_entry_fn, then call __native_run_loop. ElAppDelegate invokes
+ * el_main_entry_fn inside didFinishLaunchingWithOptions.
+ * Call el_uikit_set_args(argc, argv) from main() before __native_run_loop. */
+
+#ifdef EL_TARGET_IOS
+
+/* Lifecycle entry-function hook — set before calling __native_run_loop. */
+extern void (*el_main_entry_fn)(void);
+
+/* Forward argc/argv from main() to UIApplicationMain. */
+void el_uikit_set_args(int argc, char** argv);
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_IOS */
+
+/* ── Native widget system (Android JNI) ─────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_ANDROID and linked with
+ * libelruntime.so (which includes el_android.c compiled by the NDK build).
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Java companion: ElBridge.java (package com.neuron.el) must be compiled into
+ * the APK. The Activity must call ElBridge.init(this) before any widget ops.
+ *
+ * Link flags (in Android.mk or CMakeLists.txt):
+ * -landroid -llog -ldl */
+
+#ifdef EL_TARGET_ANDROID
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void); /* no-op on Android */
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_ANDROID */
+
+/* ── Native widget system (LVGL v9 — embedded / microcontroller) ─────────────
+ * Available when compiled with -DEL_TARGET_LVGL and linked with el_lvgl.c
+ * and the LVGL library (lvgl.a or lvgl source tree).
+ *
+ * Target platforms: ESP32, STM32, industrial panels. Any system with 256KB+
+ * RAM and an LVGL-compatible display driver. No OS required.
+ *
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Bare-metal / no dynamic linker:
+ * Compile with -DEL_LVGL_NO_DLSYM and provide:
+ * el_val_t el_lvgl_dispatch(const char *fn, el_val_t a, el_val_t b);
+ *
+ * Compile:
+ * gcc -DEL_TARGET_LVGL -I./lvgl el_lvgl.c -c -o el_lvgl.o
+ * # Then link with lvgl.a. */
+
+#ifdef EL_TARGET_LVGL
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as all other native targets */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LVGL */
+
+/* ── Native widget system (SDL2 — embedded / Pi) ────────────────────────────
+ * Available when compiled with -DEL_TARGET_SDL2 and linked with el_sdl2.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Target: Raspberry Pi Zero, embedded Linux, any system with a framebuffer
+ * and SDL2 available. No GTK, no desktop environment required.
+ *
+ * Compile:
+ * gcc -DEL_TARGET_SDL2 $(sdl2-config --cflags) -c el_sdl2.c -o el_sdl2.o
+ * Link:
+ * $(sdl2-config --libs) -lSDL2_ttf -lSDL2_image -ldl */
+
+#ifdef EL_TARGET_SDL2
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_SDL2 */
diff --git a/ui/examples/native-hello-ios/NativeHello/el_native_vessel.c b/ui/examples/native-hello-ios/NativeHello/el_native_vessel.c
new file mode 100644
index 0000000..0682965
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_native_vessel.c
@@ -0,0 +1,459 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t native_init(void);
+el_val_t native_run_loop(void);
+el_val_t manifest_read(el_val_t path);
+el_val_t manifest_title(el_val_t m);
+el_val_t manifest_width(el_val_t m);
+el_val_t manifest_height(el_val_t m);
+el_val_t manifest_min_width(el_val_t m);
+el_val_t manifest_min_height(el_val_t m);
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height);
+el_val_t window_from_manifest(el_val_t manifest_path);
+el_val_t window_show(el_val_t handle);
+el_val_t window_set_title(el_val_t handle, el_val_t title);
+el_val_t vstack(el_val_t spacing);
+el_val_t vstack_tight(void);
+el_val_t hstack(el_val_t spacing);
+el_val_t zstack(void);
+el_val_t scroll(void);
+el_val_t label(el_val_t text);
+el_val_t button(el_val_t label);
+el_val_t text_field(el_val_t placeholder);
+el_val_t text_area(el_val_t placeholder);
+el_val_t image(el_val_t path_or_name);
+el_val_t widget_set_text(el_val_t handle, el_val_t text);
+el_val_t widget_get_text(el_val_t handle);
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold);
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left);
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p);
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py);
+el_val_t widget_set_width(el_val_t handle, el_val_t width);
+el_val_t widget_set_height(el_val_t handle, el_val_t height);
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex);
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius);
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled);
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden);
+el_val_t widget_add_child(el_val_t parent, el_val_t child);
+el_val_t widget_remove_child(el_val_t parent, el_val_t child);
+el_val_t widget_destroy(el_val_t handle);
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name);
+el_val_t hex_channel(el_val_t s, el_val_t offset);
+el_val_t hex_nibble(el_val_t c);
+el_val_t color_hex_r(el_val_t hex);
+el_val_t color_hex_g(el_val_t hex);
+el_val_t color_hex_b(el_val_t hex);
+el_val_t color_hex_a(el_val_t hex);
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex);
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex);
+el_val_t style_surface(el_val_t handle);
+el_val_t style_button_primary(el_val_t handle);
+el_val_t style_label_body(el_val_t handle);
+el_val_t style_label_heading(el_val_t handle);
+el_val_t style_label_muted(el_val_t handle);
+
+el_val_t TOKEN_PRIMARY;
+el_val_t TOKEN_ON_PRIMARY;
+el_val_t TOKEN_BACKGROUND;
+el_val_t TOKEN_ON_BG;
+el_val_t TOKEN_SURFACE;
+el_val_t TOKEN_ON_SURFACE;
+el_val_t TOKEN_OUTLINE;
+el_val_t TOKEN_ERROR;
+
+el_val_t native_init(void) {
+ __native_init();
+ return 0;
+}
+
+el_val_t native_run_loop(void) {
+ __native_run_loop();
+ return 0;
+}
+
+el_val_t manifest_read(el_val_t path) {
+ return __manifest_read(path);
+ return 0;
+}
+
+el_val_t manifest_title(el_val_t m) {
+ return json_get_string(m, EL_STR("title"));
+ return 0;
+}
+
+el_val_t manifest_width(el_val_t m) {
+ return json_get_int(m, EL_STR("width"));
+ return 0;
+}
+
+el_val_t manifest_height(el_val_t m) {
+ return json_get_int(m, EL_STR("height"));
+ return 0;
+}
+
+el_val_t manifest_min_width(el_val_t m) {
+ return json_get_int(m, EL_STR("min_width"));
+ return 0;
+}
+
+el_val_t manifest_min_height(el_val_t m) {
+ return json_get_int(m, EL_STR("min_height"));
+ return 0;
+}
+
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height) {
+ return __window_create(title, width, height, min_width, min_height);
+ return 0;
+}
+
+el_val_t window_from_manifest(el_val_t manifest_path) {
+ el_val_t m = manifest_read(manifest_path);
+ el_val_t title = manifest_title(m);
+ el_val_t w = manifest_width(m);
+ el_val_t h = manifest_height(m);
+ el_val_t mw = manifest_min_width(m);
+ el_val_t mh = manifest_min_height(m);
+ el_val_t safe_w = ({ el_val_t _if_result_1 = 0; if ((w > 0)) { _if_result_1 = (w); } else { _if_result_1 = (1200); } _if_result_1; });
+ el_val_t safe_h = ({ el_val_t _if_result_2 = 0; if ((h > 0)) { _if_result_2 = (h); } else { _if_result_2 = (800); } _if_result_2; });
+ el_val_t safe_mw = ({ el_val_t _if_result_3 = 0; if ((mw > 0)) { _if_result_3 = (mw); } else { _if_result_3 = (600); } _if_result_3; });
+ el_val_t safe_mh = ({ el_val_t _if_result_4 = 0; if ((mh > 0)) { _if_result_4 = (mh); } else { _if_result_4 = (400); } _if_result_4; });
+ el_val_t safe_t = ({ el_val_t _if_result_5 = 0; if ((str_len(title) > 0)) { _if_result_5 = (title); } else { _if_result_5 = (EL_STR("App")); } _if_result_5; });
+ return window_create(safe_t, safe_w, safe_h, safe_mw, safe_mh);
+ return 0;
+}
+
+el_val_t window_show(el_val_t handle) {
+ __window_show(handle);
+ return 0;
+}
+
+el_val_t window_set_title(el_val_t handle, el_val_t title) {
+ __window_set_title(handle, title);
+ return 0;
+}
+
+el_val_t vstack(el_val_t spacing) {
+ return __vstack_create(spacing);
+ return 0;
+}
+
+el_val_t vstack_tight(void) {
+ return __vstack_create(0);
+ return 0;
+}
+
+el_val_t hstack(el_val_t spacing) {
+ return __hstack_create(spacing);
+ return 0;
+}
+
+el_val_t zstack(void) {
+ return __zstack_create();
+ return 0;
+}
+
+el_val_t scroll(void) {
+ return __scroll_create();
+ return 0;
+}
+
+el_val_t label(el_val_t text) {
+ return __label_create(text);
+ return 0;
+}
+
+el_val_t button(el_val_t label) {
+ return __button_create(label);
+ return 0;
+}
+
+el_val_t text_field(el_val_t placeholder) {
+ return __text_field_create(placeholder);
+ return 0;
+}
+
+el_val_t text_area(el_val_t placeholder) {
+ return __text_area_create(placeholder);
+ return 0;
+}
+
+el_val_t image(el_val_t path_or_name) {
+ return __image_create(path_or_name);
+ return 0;
+}
+
+el_val_t widget_set_text(el_val_t handle, el_val_t text) {
+ __widget_set_text(handle, text);
+ return 0;
+}
+
+el_val_t widget_get_text(el_val_t handle) {
+ return __widget_get_text(handle);
+ return 0;
+}
+
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_bg_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold) {
+ el_val_t bold_int = ({ el_val_t _if_result_6 = 0; if (bold) { _if_result_6 = (1); } else { _if_result_6 = (0); } _if_result_6; });
+ __widget_set_font(handle, family, size, bold_int);
+ return 0;
+}
+
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left) {
+ __widget_set_padding(handle, top, right, bottom, left);
+ return 0;
+}
+
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p) {
+ __widget_set_padding(handle, p, p, p, p);
+ return 0;
+}
+
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py) {
+ __widget_set_padding(handle, py, px, py, px);
+ return 0;
+}
+
+el_val_t widget_set_width(el_val_t handle, el_val_t width) {
+ __widget_set_width(handle, width);
+ return 0;
+}
+
+el_val_t widget_set_height(el_val_t handle, el_val_t height) {
+ __widget_set_height(handle, height);
+ return 0;
+}
+
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex) {
+ __widget_set_flex(handle, flex);
+ return 0;
+}
+
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius) {
+ __widget_set_corner_radius(handle, radius);
+ return 0;
+}
+
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled) {
+ el_val_t d = ({ el_val_t _if_result_7 = 0; if (disabled) { _if_result_7 = (1); } else { _if_result_7 = (0); } _if_result_7; });
+ __widget_set_disabled(handle, d);
+ return 0;
+}
+
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden) {
+ el_val_t h = ({ el_val_t _if_result_8 = 0; if (hidden) { _if_result_8 = (1); } else { _if_result_8 = (0); } _if_result_8; });
+ __widget_set_hidden(handle, h);
+ return 0;
+}
+
+el_val_t widget_add_child(el_val_t parent, el_val_t child) {
+ __widget_add_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_remove_child(el_val_t parent, el_val_t child) {
+ __widget_remove_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_destroy(el_val_t handle) {
+ __widget_destroy(handle);
+ return 0;
+}
+
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name) {
+ __widget_on_click(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name) {
+ __widget_on_change(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name) {
+ __widget_on_submit(handle, fn_name);
+ return 0;
+}
+
+el_val_t hex_channel(el_val_t s, el_val_t offset) {
+ el_val_t hi = str_slice(s, offset, (offset + 1));
+ el_val_t lo = str_slice(s, (offset + 1), (offset + 2));
+ el_val_t h = hex_nibble(hi);
+ el_val_t l = hex_nibble(lo);
+ return ((h * 16) + l);
+ return 0;
+}
+
+el_val_t hex_nibble(el_val_t c) {
+ if (str_eq(c, EL_STR("0"))) {
+ return 0;
+ }
+ if (str_eq(c, EL_STR("1"))) {
+ return 1;
+ }
+ if (str_eq(c, EL_STR("2"))) {
+ return 2;
+ }
+ if (str_eq(c, EL_STR("3"))) {
+ return 3;
+ }
+ if (str_eq(c, EL_STR("4"))) {
+ return 4;
+ }
+ if (str_eq(c, EL_STR("5"))) {
+ return 5;
+ }
+ if (str_eq(c, EL_STR("6"))) {
+ return 6;
+ }
+ if (str_eq(c, EL_STR("7"))) {
+ return 7;
+ }
+ if (str_eq(c, EL_STR("8"))) {
+ return 8;
+ }
+ if (str_eq(c, EL_STR("9"))) {
+ return 9;
+ }
+ if (str_eq(c, EL_STR("a"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("b"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("c"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("d"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("e"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("f"))) {
+ return 15;
+ }
+ if (str_eq(c, EL_STR("A"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("B"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("C"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("D"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("E"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("F"))) {
+ return 15;
+ }
+ return 0;
+ return 0;
+}
+
+el_val_t color_hex_r(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_9 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_9 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_9 = (hex); } _if_result_9; });
+ el_val_t v = hex_channel(s, 0);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_g(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_10 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_10 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_10 = (hex); } _if_result_10; });
+ el_val_t v = hex_channel(s, 2);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_b(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_11 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_11 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_11 = (hex); } _if_result_11; });
+ el_val_t v = hex_channel(s, 4);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_a(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_12 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_12 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_12 = (hex); } _if_result_12; });
+ if (str_len(s) < 8) {
+ return el_from_float(1.0);
+ }
+ el_val_t v = hex_channel(s, 6);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_bg_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t style_surface(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_SURFACE);
+ widget_set_corner_radius(handle, 8);
+ return 0;
+}
+
+el_val_t style_button_primary(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_PRIMARY);
+ widget_set_color_hex(handle, TOKEN_ON_PRIMARY);
+ widget_set_corner_radius(handle, 8);
+ widget_set_padding_xy(handle, 16, 8);
+ return 0;
+}
+
+el_val_t style_label_body(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 14, 0);
+ return 0;
+}
+
+el_val_t style_label_heading(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 20, 1);
+ return 0;
+}
+
+el_val_t style_label_muted(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_OUTLINE);
+ widget_set_font(handle, EL_STR("system"), 12, 0);
+ return 0;
+}
+
+int el_vessel_main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ TOKEN_PRIMARY = EL_STR("#60a5fa");
+ TOKEN_ON_PRIMARY = EL_STR("#0f172a");
+ TOKEN_BACKGROUND = EL_STR("#0f172a");
+ TOKEN_ON_BG = EL_STR("#f8fafc");
+ TOKEN_SURFACE = EL_STR("#1e293b");
+ TOKEN_ON_SURFACE = EL_STR("#f8fafc");
+ TOKEN_OUTLINE = EL_STR("#475569");
+ TOKEN_ERROR = EL_STR("#f87171");
+ return 0;
+}
+
diff --git a/ui/examples/native-hello-ios/NativeHello/el_runtime.c b/ui/examples/native-hello-ios/NativeHello/el_runtime.c
new file mode 100644
index 0000000..a735c9f
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_runtime.c
@@ -0,0 +1,11733 @@
+/*
+ * 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 -lcurl -lpthread -o .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
+#include /* strcasecmp */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include /* dlsym for http_set_handler fallback */
+#include
+#include
+#include
+#include
+#include
+#include /* getrusage — memory guard */
+#ifdef HAVE_CURL
+#include
+#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;
+
+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, "= 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), and *out_body (allocated). On failure returns 0.
+ *
+ * Implementation: feeds the entire envelope through the recursive-descent
+ * JSON parser (which builds proper ElMap/ElList values), then pulls the
+ * three top-level fields by name. Avoids re-stringifying the headers map
+ * since json_stringify() does not support nested objects. */
+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;
+
+ el_val_t parsed = json_parse(EL_STR(s));
+ if (parsed == EL_NULL) return 0;
+
+ int status = 200;
+ el_val_t hmap = 0;
+ char* body = NULL;
+
+ el_val_t sv = el_map_get(parsed, EL_STR("status"));
+ if (sv != 0) {
+ /* status comes back as an integer — el_val_t holds it directly. */
+ long sc = (long)sv;
+ if (sc >= 100 && sc <= 599) status = (int)sc;
+ }
+
+ el_val_t hv = el_map_get(parsed, EL_STR("headers"));
+ if (hv != 0) {
+ ElMap* hm = (ElMap*)(uintptr_t)hv;
+ if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
+ }
+
+ el_val_t bv = el_map_get(parsed, EL_STR("body"));
+ if (bv != 0) {
+ const char* bs = EL_CSTR(bv);
+ if (bs) body = el_strdup(bs);
+ }
+ if (!body) body = el_strdup("");
+
+ *out_status = status;
+ *out_headers_map = hmap;
+ *out_body = body;
+ *out_parsed_root = parsed; /* caller releases to free hmap + entries */
+ 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__":,...}` 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;
+ /* 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 = (_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);
+}
+
+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 = "";
+
+ 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\":");
+ 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
+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 (") */
+ 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, "&", 5); p += 5; break;
+ case '<': memcpy(p, "<", 4); p += 4; break;
+ case '>': memcpy(p, ">", 4); p += 4; break;
+ case '"': memcpy(p, """, 6); p += 6; break;
+ case '\'': memcpy(p, "'", 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 `` is
+ * CDATA-like and must not be re-emitted as escaped text either.
+ * - Comments (), doctype (), 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 `` 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 (<, &,
+ * 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, "<"); break;
+ case '>': html_buf_puts(out, ">"); break;
+ case '"': html_buf_puts(out, """); break;
+ case '\'': html_buf_puts(out, "'"); 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 (`<`, `&`, `...;`)
+ * 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, "<");
+ 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, "<");
+ 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, "<");
+ 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 ``. */
+ 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, """);
+ else if (y == '<') html_buf_puts(&out, "<");
+ else if (y == '>') html_buf_puts(&out, ">");
+ 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_ 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 (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;
+ }
+
+ /* ── TRAVERSAL INFERENCE: infer A→C edges when A→B→C was traversed ──
+ * For each pair of edges (A→B, B→C) where all three nodes were reached,
+ * create an inferred A→C edge with weight = w(A→B) * w(B→C) * 0.8
+ * if no A→C edge already exists. Cap at 64 new edges per call.
+ *
+ * IMPORTANT: collect candidates FIRST into a flat array (no pointers into
+ * g->edges held across the apply pass), then apply after — this avoids
+ * dangling pointer bugs if engram_grow_edges() reallocs the array. */
+ {
+ const int64_t INFER_CAP = 64;
+ typedef struct { char from[64]; char to[64]; double weight; } InferCandidate;
+ InferCandidate* cands = malloc((size_t)INFER_CAP * sizeof(InferCandidate));
+ int64_t ncands = 0;
+ int64_t snap_ec = g->edge_count;
+ if (cands) {
+ for (int64_t e1 = 0; e1 < snap_ec && ncands < INFER_CAP; e1++) {
+ EngramEdge* ea = &g->edges[e1];
+ if (!ea->from_id || !ea->to_id) continue;
+ int64_t ai = engram_find_node_index(ea->from_id);
+ int64_t bi = engram_find_node_index(ea->to_id);
+ if (ai < 0 || bi < 0) continue;
+ if (!reached[ai] || !reached[bi]) continue;
+ for (int64_t e2 = 0; e2 < snap_ec && ncands < INFER_CAP; e2++) {
+ if (e2 == e1) continue;
+ EngramEdge* eb = &g->edges[e2];
+ if (!eb->from_id || !eb->to_id) continue;
+ if (strcmp(eb->from_id, ea->to_id) != 0) continue;
+ int64_t ci = engram_find_node_index(eb->to_id);
+ if (ci < 0 || !reached[ci]) continue;
+ if (ai == ci) continue;
+ int already = 0;
+ for (int64_t ex = 0; ex < snap_ec; ex++) {
+ EngramEdge* ee = &g->edges[ex];
+ if (ee->from_id && ee->to_id &&
+ strcmp(ee->from_id, ea->from_id) == 0 &&
+ strcmp(ee->to_id, eb->to_id) == 0) {
+ already = 1; break;
+ }
+ }
+ if (already) continue;
+ int dup = 0;
+ for (int64_t k = 0; k < ncands; k++) {
+ if (strcmp(cands[k].from, ea->from_id) == 0 &&
+ strcmp(cands[k].to, eb->to_id) == 0) { dup = 1; break; }
+ }
+ if (dup) continue;
+ double inf_w = ea->weight * eb->weight * 0.8;
+ if (inf_w < 0.05) inf_w = 0.05;
+ if (inf_w > 1.0) inf_w = 1.0;
+ strncpy(cands[ncands].from, ea->from_id, 63); cands[ncands].from[63] = '\0';
+ strncpy(cands[ncands].to, eb->to_id, 63); cands[ncands].to[63] = '\0';
+ cands[ncands].weight = inf_w;
+ ncands++;
+ }
+ }
+ for (int64_t k = 0; k < ncands; k++) {
+ engram_grow_edges();
+ EngramEdge* ne = &g->edges[g->edge_count];
+ memset(ne, 0, sizeof(*ne));
+ ne->id = engram_new_id();
+ /* Use strdup (not el_strdup) so these persist beyond the request. */
+ ne->from_id = strdup(cands[k].from);
+ ne->to_id = strdup(cands[k].to);
+ ne->relation = strdup("inferred");
+ ne->metadata = strdup("{}");
+ ne->weight = cands[k].weight;
+ ne->confidence = 0.8;
+ ne->created_at = now_ms;
+ ne->updated_at = now_ms;
+ ne->last_fired = now_ms;
+ ne->layer_id = ENGRAM_LAYER_DEFAULT;
+ g->edge_count++;
+ }
+ free(cands);
+ }
+ }
+
+ /* ── 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];
+ }
+
+ /* ── HEBBIAN STRENGTHENING: fire together, wire together ─────────────
+ * For each pair of co-promoted nodes (working_memory_weight > 0) that
+ * share an edge, boost that edge's weight by 0.05 (capped at 1.0).
+ * Also increment activation_count and update last_activated on promoted
+ * nodes — this is what drives tier migration below. */
+ for (int64_t i = 0; i < g->node_count; i++) {
+ if (wm_weights[i] <= 0.0) continue;
+ EngramNode* n = &g->nodes[i];
+ n->activation_count++;
+ n->last_activated = now_ms;
+ n->updated_at = now_ms;
+ }
+ for (int64_t ei = 0; ei < g->edge_count; ei++) {
+ EngramEdge* e = &g->edges[ei];
+ if (!e->from_id || !e->to_id) 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 (wm_weights[src] > 0.0 && wm_weights[tgt] > 0.0) {
+ e->weight += 0.05;
+ if (e->weight > 1.0) e->weight = 1.0;
+ e->last_fired = now_ms;
+ e->updated_at = now_ms;
+ }
+ }
+
+ /* ── TIER MIGRATION: promote nodes based on activation_count thresholds ─
+ * 0–4 → Working
+ * 5–19 → Episodic
+ * 20–49 → Semantic
+ * 50+ → Procedural
+ * Only upgrade (never downgrade) to preserve earned tier. */
+ for (int64_t i = 0; i < g->node_count; i++) {
+ EngramNode* n = &g->nodes[i];
+ const char* target_tier = NULL;
+ int64_t ac = n->activation_count;
+ if (ac >= 50) target_tier = "Procedural";
+ else if (ac >= 20) target_tier = "Semantic";
+ else if (ac >= 5) target_tier = "Episodic";
+ else target_tier = "Working";
+ if (target_tier && n->tier && strcmp(n->tier, target_tier) != 0) {
+ /* Only upgrade (Working < Episodic < Semantic < Procedural). */
+ int cur_rank = 0, new_rank = 0;
+ if (strcmp(n->tier, "Working") == 0) cur_rank = 0;
+ else if (strcmp(n->tier, "Episodic") == 0) cur_rank = 1;
+ else if (strcmp(n->tier, "Semantic") == 0) cur_rank = 2;
+ else if (strcmp(n->tier, "Procedural") == 0) cur_rank = 3;
+ if (strcmp(target_tier, "Working") == 0) new_rank = 0;
+ else if (strcmp(target_tier, "Episodic") == 0) new_rank = 1;
+ else if (strcmp(target_tier, "Semantic") == 0) new_rank = 2;
+ else if (strcmp(target_tier, "Procedural") == 0) new_rank = 3;
+ if (new_rank > cur_rank) {
+ free(n->tier);
+ /* Use strdup (not el_strdup) so tier string persists beyond the request. */
+ n->tier = strdup(target_tier);
+ n->updated_at = now_ms;
+ }
+ }
+ }
+
+ /* ── 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);
+}
+
+/* ── engram_apply_decay_json — temporal decay maintenance ────────────────────
+ *
+ * Iterates ALL nodes and applies temporal decay to their stored `salience`
+ * field based on time elapsed since `last_activated`:
+ *
+ * new_salience = current_salience * decay_rate ^ hours_since_activation
+ *
+ * where decay_rate defaults to 0.5^(1/168) per hour (half-life one week),
+ * or the node's own `temporal_decay_rate` if non-zero.
+ *
+ * Nodes with temporal_decay_rate == 0 are NOT immune — the global default
+ * applies. To make a node truly immune, set temporal_decay_rate to a very
+ * small positive value (e.g. 0.0001). Nodes that are "pinned" can be
+ * identified by a tier of "Procedural" — those are skipped.
+ *
+ * After updating salience, nodes with salience < 0.05 AND tier == "Working"
+ * are pruned (deleted) unless they have no content (guard against garbage).
+ *
+ * Returns a JSON summary: {"updated": N, "pruned": N} */
+el_val_t engram_apply_decay_json(void) {
+ EngramStore* g = engram_get();
+ int64_t now_ms = engram_now_ms();
+ int64_t updated = 0, pruned = 0;
+
+ for (int64_t i = 0; i < g->node_count; i++) {
+ EngramNode* n = &g->nodes[i];
+ /* Skip Procedural nodes (they are "locked in"). */
+ if (n->tier && strcmp(n->tier, "Procedural") == 0) continue;
+ int64_t age_ms = now_ms - n->last_activated;
+ if (age_ms <= 0) continue;
+ double lambda = (n->temporal_decay_rate > 0.0) ? n->temporal_decay_rate
+ : ENGRAM_DECAY_LAMBDA;
+ double age_hours = (double)age_ms / 3600000.0;
+ double decay_factor = exp(-lambda * age_hours / ENGRAM_T_HALF_HOURS);
+ double new_salience = n->salience * decay_factor;
+ if (new_salience < 0.0) new_salience = 0.0;
+ if (new_salience != n->salience) {
+ n->salience = new_salience;
+ n->updated_at = now_ms;
+ updated++;
+ }
+ }
+
+ /* Prune low-salience Working nodes. Walk backwards to allow in-place
+ * removal without invalidating indices. */
+ for (int64_t i = g->node_count - 1; i >= 0; i--) {
+ EngramNode* n = &g->nodes[i];
+ if (n->salience >= 0.05) continue;
+ /* Only prune Working tier nodes — higher tiers are protected. */
+ if (!n->tier || strcmp(n->tier, "Working") != 0) continue;
+ /* Guard: skip nodes with no content. */
+ if (!n->content || !*n->content) continue;
+ /* Free node strings. */
+ 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 j = i + 1; j < g->node_count; j++) {
+ g->nodes[j - 1] = g->nodes[j];
+ }
+ g->node_count--;
+ memset(&g->nodes[g->node_count], 0, sizeof(EngramNode));
+ pruned++;
+ }
+
+ /* Remove dangling edges for pruned nodes (any edge whose endpoint no
+ * longer exists in the node list). */
+ if (pruned > 0) {
+ int64_t w = 0;
+ for (int64_t r = 0; r < g->edge_count; r++) {
+ EngramEdge* e = &g->edges[r];
+ int dangling = 0;
+ if (e->from_id && engram_find_node_index(e->from_id) < 0) dangling = 1;
+ if (e->to_id && engram_find_node_index(e->to_id) < 0) dangling = 1;
+ if (dangling) {
+ 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;
+ }
+
+ char buf[128];
+ snprintf(buf, sizeof(buf),
+ "{\"ok\":true,\"updated\":%lld,\"pruned\":%lld}",
+ (long long)updated, (long long)pruned);
+ return el_wrap_str(el_strdup(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
+ * "@" where is the peer's Engram-exposed daemon.
+ *
+ * Channels are logical handles cached per-cgi: `dharma_connect` is
+ * idempotent and returns "ch:". 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:"). 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 @ suffix */
+ char* base_id; /* registry-id portion (before @) for relationship lookup */
+ char* url; /* resolved transport URL */
+ char* channel_id; /* "ch:" */
+} 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 "@" → (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:". 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 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()
+#include
+#define EL_HAVE_LIBOQS 1
+#else
+#define EL_HAVE_LIBOQS 0
+#endif
+
+#if EL_HAVE_LIBOQS && __has_include()
+#include
+#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()
+
+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
+#include
+
+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() */
+
+#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 */
diff --git a/ui/examples/native-hello-ios/NativeHello/el_runtime.h b/ui/examples/native-hello-ios/NativeHello/el_runtime.h
new file mode 100644
index 0000000..2f9583f
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_runtime.h
@@ -0,0 +1,883 @@
+/*
+ * el_runtime.h — El language C runtime header
+ *
+ * Declares all built-in functions available to compiled El programs.
+ * Include this in every generated .c file.
+ *
+ * Value model:
+ * All El values are represented as el_val_t (= int64_t).
+ * On 64-bit systems a pointer fits in int64_t.
+ * String values are cast: (el_val_t)(uintptr_t)"hello"
+ * Integer values are stored directly.
+ * This lets arithmetic work naturally while still passing strings around.
+ *
+ * Type conventions (El -> C):
+ * String -> el_val_t (holds const char* via uintptr_t cast)
+ * Int -> el_val_t
+ * Bool -> el_val_t (0 = false, nonzero = true)
+ * Any -> el_val_t
+ * Void -> void
+ *
+ * Macros for convenience:
+ * EL_STR(s) cast string literal to el_val_t
+ * EL_CSTR(v) cast el_val_t back to const char*
+ * EL_INT(v) identity — el_val_t is already int64_t
+ * EL_NULL null / zero value
+ * EL_FALSE boolean false (0)
+ * EL_TRUE boolean true (1)
+ *
+ * Link requirements:
+ * -lcurl — required for the HTTP client (http_get, http_post, llm_*).
+ * -lpthread — required for the HTTP server (one detached thread per
+ * connection, capped at 64 concurrent).
+ * -loqs — optional; required only when liboqs is installed and the
+ * pq_* / sha3_256_hex entry points are needed. Detected at
+ * compile time via __has_include().
+ * -lcrypto — optional; pulled in alongside -loqs. Used for X25519 in
+ * pq_hybrid_* and HKDF-SHA256 derivation.
+ *
+ * Canonical compile command:
+ * cc -std=c11 -I el-compiler/runtime -lcurl -lpthread \
+ * -o .c el-compiler/runtime/el_runtime.c
+ *
+ * With liboqs (post-quantum stack):
+ * cc -std=c11 -I el-compiler/runtime -lcurl -lpthread -loqs -lcrypto \
+ * -o .c el-compiler/runtime/el_runtime.c
+ */
+
+#pragma once
+
+#include
+#include
+
+typedef int64_t el_val_t;
+
+#define EL_STR(s) ((el_val_t)(uintptr_t)(s))
+#define EL_CSTR(v) ((const char*)(uintptr_t)(v))
+#define EL_INT(v) (v)
+#define EL_NULL ((el_val_t)0)
+#define EL_FALSE ((el_val_t)0)
+#define EL_TRUE ((el_val_t)1)
+
+/* Float values share the el_val_t (int64) slot via a bit-cast.
+ * The codegen emits Float literals as `el_from_float()` so the
+ * underlying bits represent the IEEE 754 double. Float-aware builtins
+ * (math, format, json) round-trip via these helpers. */
+static inline double el_to_float(el_val_t v) {
+ union { int64_t i; double f; } u;
+ u.i = (int64_t)v;
+ return u.f;
+}
+
+static inline el_val_t el_from_float(double f) {
+ union { double f; int64_t i; } u;
+ u.f = f;
+ return (el_val_t)u.i;
+}
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/* ── I/O ──────────────────────────────────────────────────────────────────── */
+
+el_val_t println(el_val_t s);
+el_val_t print(el_val_t s);
+el_val_t readline(void);
+
+/* ── String builtins ─────────────────────────────────────────────────────── */
+
+el_val_t el_str_concat(el_val_t a, el_val_t b);
+el_val_t str_eq(el_val_t a, el_val_t b);
+el_val_t str_starts_with(el_val_t s, el_val_t prefix);
+el_val_t str_ends_with(el_val_t s, el_val_t suffix);
+el_val_t str_len(el_val_t s);
+el_val_t str_concat(el_val_t a, el_val_t b);
+el_val_t int_to_str(el_val_t n);
+el_val_t str_to_int(el_val_t s);
+el_val_t native_str_to_int(el_val_t s);
+el_val_t str_slice(el_val_t s, el_val_t start, el_val_t end);
+el_val_t str_contains(el_val_t s, el_val_t sub);
+el_val_t str_replace(el_val_t s, el_val_t from, el_val_t to);
+el_val_t str_to_upper(el_val_t s);
+el_val_t str_to_lower(el_val_t s);
+el_val_t str_trim(el_val_t s);
+
+/* ── Math ────────────────────────────────────────────────────────────────── */
+
+el_val_t el_abs(el_val_t n);
+el_val_t el_max(el_val_t a, el_val_t b);
+el_val_t el_min(el_val_t a, el_val_t b);
+
+/* ── Refcount (ARC) ──────────────────────────────────────────────────────────
+ * Lists and Maps carry a refcount. Strings and ints do not — el_retain and
+ * el_release are safe no-ops on non-refcounted values (they sniff a magic
+ * header at offset 0 and only act if the magic matches).
+ *
+ * Codegen emits these at let-binding shadowing, function entry (params), and
+ * function exit (locals other than the returned value). The refcount lets
+ * el_list_append and el_map_set mutate in place when uniquely owned (cheap)
+ * and copy-on-write when shared (preserves persistent semantics across
+ * accumulator patterns in the compiler itself). */
+
+void el_retain(el_val_t v);
+void el_release(el_val_t v);
+
+/* ── Scoped arena (CLI use) ───────────────────────────────────────────────── */
+el_val_t el_arena_push(void);
+el_val_t el_arena_pop(el_val_t mark);
+
+/* ── List ────────────────────────────────────────────────────────────────── */
+
+el_val_t el_list_new(el_val_t count, ...);
+el_val_t el_list_len(el_val_t list);
+el_val_t el_list_get(el_val_t list, el_val_t index);
+el_val_t el_list_append(el_val_t list, el_val_t elem);
+el_val_t el_list_empty(void);
+el_val_t el_list_clone(el_val_t list);
+
+/* ── Map ─────────────────────────────────────────────────────────────────── */
+
+el_val_t el_map_new(el_val_t pair_count, ...);
+el_val_t el_get_field(el_val_t map, el_val_t key);
+el_val_t el_map_get(el_val_t map, el_val_t key);
+el_val_t el_map_set(el_val_t map, el_val_t key, el_val_t value);
+
+/* ── HTTP ─────────────────────────────────────────────────────────────────── */
+
+el_val_t http_get(el_val_t url);
+el_val_t http_post(el_val_t url, el_val_t body);
+el_val_t http_post_json(el_val_t url, el_val_t json_body);
+el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map);
+el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map);
+el_val_t http_post_json_with_headers(el_val_t url, el_val_t headers_map, el_val_t json_body);
+el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header);
+el_val_t http_delete(el_val_t url);
+el_val_t http_serve(el_val_t port, el_val_t handler);
+el_val_t http_set_handler(el_val_t name);
+
+/* HTTP server v2 ─────────────────────────────────────────────────────────────
+ * Same dispatch model as http_serve, but the handler signature is widened:
+ *
+ * el_val_t handler(method, path, headers_map, body)
+ *
+ * `headers_map` is an ElMap from lowercased header name → header value (both
+ * Strings). Repeated headers are joined with ", " per RFC 7230.
+ *
+ * Response value: the handler may return either
+ * (a) a plain body string — same auto-content-type / 200-OK behaviour as
+ * http_serve (3-arg) — or
+ * (b) a response envelope built with `http_response(status, headers_json,
+ * body)`. The runtime detects the envelope discriminator
+ * `"el_http_response":1` at the start of the returned string and
+ * unpacks status / headers / body before sending.
+ *
+ * The 3-arg http_serve(port, handler) remains supported unchanged for
+ * existing handlers (e.g. products/web/server.el): it dispatches with
+ * (method, path, body), hardcodes 200 OK, and auto-detects content type. */
+el_val_t http_serve_v2(el_val_t port, el_val_t handler);
+el_val_t http_set_handler_v2(el_val_t name);
+
+/* Build an HTTP response envelope. `headers_json` should be a JSON object
+ * literal like `{"WWW-Authenticate":"Basic"}` (or "" / "{}" for none). The
+ * returned string carries the discriminator `{"el_http_response":1,...}`
+ * which the runtime's send-path detects and unpacks. Detection happens
+ * uniformly inside http_send_response, so a 3-arg handler may also return
+ * an envelope. The 3-arg variant remains documented as a fixed 200-OK
+ * auto-content-type contract for legacy handlers that return plain bodies. */
+el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+
+/* SSE connection fd — set by http_worker_v2 before calling the El handler,
+ * cleared afterwards. Defined in el_seed.c; called from el_runtime.c.
+ * The getter is exposed as __http_conn_fd() to El programs. */
+void el_seed_set_http_conn_fd(int fd);
+
+/* HTTP timeout — every libcurl request honors EL_HTTP_TIMEOUT_MS (default
+ * 60000ms). Read lazily on first use, so setting the env var any time before
+ * the first http_* call is sufficient. */
+
+/* Streaming variants — write the response body straight to a file via
+ * libcurl's CURLOPT_WRITEFUNCTION = fwrite. These bypass the el_val_t string
+ * wrapper entirely, so binary payloads (audio/mpeg, image/png, etc.) survive
+ * embedded NUL bytes that would truncate a strlen()-based code path.
+ *
+ * Both honor EL_HTTP_TIMEOUT_MS, follow redirects, and accept the same
+ * `headers_map` shape as http_post_with_headers (ElMap of String→String).
+ *
+ * Return value: 1 on success (file fully written), 0 on any failure
+ * (network, file open, partial write). On failure the output file is removed
+ * so callers cannot mistake a partially-written file for a valid one. */
+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);
+el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_path);
+
+/* ── URL encoding ────────────────────────────────────────────────────────── */
+
+el_val_t url_encode(el_val_t s); /* RFC 3986 unreserved set */
+el_val_t url_decode(el_val_t s); /* '+' → space, %XX → byte */
+
+/* ── HTML allowlist sanitizer ────────────────────────────────────────────────
+ * el_html_sanitize(input_html, allowlist_json) — strict allowlist HTML
+ * cleaner. State-machine parser; tag/attribute names compared case-
+ * insensitively against the allowlist; `` / `<… src>` URL schemes
+ * validated (http, https, mailto, fragment-only, or relative); whole-
+ * subtree drop for script / style / iframe / object / embed / form; HTML-
+ * escapes free text outside dropped subtrees.
+ *
+ * The allowlist is JSON of the form
+ * {"p":[],"a":["href","title"],"strong":[],...}
+ * where each value is the array of attribute names allowed for that tag. */
+el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
+el_val_t html_raw(el_val_t s);
+el_val_t html_escape(el_val_t s);
+
+/* ── Filesystem ──────────────────────────────────────────────────────────── */
+
+el_val_t fs_read(el_val_t path);
+el_val_t fs_write(el_val_t path, el_val_t content);
+el_val_t fs_list(el_val_t path);
+el_val_t fs_list_json(el_val_t path);
+el_val_t fs_exists(el_val_t path);
+el_val_t fs_mkdir(el_val_t path); /* mkdir -p, mode 0755 */
+
+/* Length-explicit binary write. `length` is an Int (el_val_t holding the
+ * byte count). The caller knows the length from context — typically because
+ * `bytes` came from base64_decode (which produces a magic-tagged binary
+ * buffer with embedded NULs possible) and the caller already tracks the
+ * decoded length, OR because the bytes came from a fixed-size source
+ * (sha256_bytes = 32, hmac_sha256_bytes = 32). Bypasses strlen entirely.
+ *
+ * Returns 1 on success, 0 on failure (invalid path, can't open, partial
+ * write, negative length). On partial-write failure, the file is removed
+ * so callers cannot read back a truncated artefact. */
+el_val_t fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t length);
+
+/* ── JSON ────────────────────────────────────────────────────────────────── */
+
+el_val_t json_get(el_val_t json, el_val_t key);
+el_val_t json_parse(el_val_t s);
+el_val_t json_stringify(el_val_t v);
+el_val_t json_get_string(el_val_t json_str, el_val_t key);
+el_val_t json_get_int(el_val_t json_str, el_val_t key);
+el_val_t json_get_float(el_val_t json_str, el_val_t key);
+el_val_t json_get_bool(el_val_t json_str, el_val_t key);
+el_val_t json_get_raw(el_val_t json_str, el_val_t key);
+el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
+el_val_t json_array_len(el_val_t json_str);
+el_val_t json_array_get(el_val_t json_str, el_val_t index);
+el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
+el_val_t json_escape_string(el_val_t sv);
+el_val_t json_build_object(el_val_t kvs);
+el_val_t json_build_array(el_val_t items);
+
+/* ── Time ────────────────────────────────────────────────────────────────── */
+
+el_val_t time_now(void);
+el_val_t time_now_utc(void);
+el_val_t sleep_secs(el_val_t secs);
+el_val_t sleep_ms(el_val_t ms);
+el_val_t time_format(el_val_t ts, el_val_t fmt);
+el_val_t time_to_parts(el_val_t ts);
+el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz);
+el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit);
+el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit);
+el_val_t now_ns(void);
+
+/* ── Instant + Duration: first-class temporal types ──────────────────────────
+ * Both types share the el_val_t (int64) slot. Instants are nanoseconds
+ * since the Unix epoch; Durations are signed nanoseconds. Type discipline
+ * is enforced at codegen-time: BinOps on names registered as Instant or
+ * Duration route through the typed wrappers below; mismatches like
+ * Instant+Instant become #error at the C compiler.
+ *
+ * Postfix literals — `30.seconds`, `1.hour`, `500.millis`, `30.nanos` — are
+ * recognised by the parser as DurationLit AST nodes and lowered to literal
+ * int64 nanoseconds at codegen time. The runtime never sees the units. */
+
+el_val_t el_now_instant(void);
+el_val_t now(void);
+el_val_t unix_seconds(el_val_t n);
+el_val_t unix_millis(el_val_t n);
+el_val_t instant_from_iso8601(el_val_t s);
+
+el_val_t el_duration_from_nanos(el_val_t ns);
+el_val_t duration_seconds(el_val_t n);
+el_val_t duration_millis(el_val_t n);
+el_val_t duration_nanos(el_val_t n);
+
+el_val_t el_instant_add_dur(el_val_t inst, el_val_t dur);
+el_val_t el_instant_sub_dur(el_val_t inst, el_val_t dur);
+el_val_t el_instant_diff(el_val_t a, el_val_t b);
+el_val_t el_duration_add(el_val_t a, el_val_t b);
+el_val_t el_duration_sub(el_val_t a, el_val_t b);
+el_val_t el_duration_scale(el_val_t dur, el_val_t scalar);
+el_val_t el_duration_div(el_val_t dur, el_val_t scalar);
+
+el_val_t el_instant_lt(el_val_t a, el_val_t b);
+el_val_t el_instant_le(el_val_t a, el_val_t b);
+el_val_t el_instant_gt(el_val_t a, el_val_t b);
+el_val_t el_instant_ge(el_val_t a, el_val_t b);
+el_val_t el_instant_eq(el_val_t a, el_val_t b);
+el_val_t el_instant_ne(el_val_t a, el_val_t b);
+el_val_t el_duration_lt(el_val_t a, el_val_t b);
+el_val_t el_duration_le(el_val_t a, el_val_t b);
+el_val_t el_duration_gt(el_val_t a, el_val_t b);
+el_val_t el_duration_ge(el_val_t a, el_val_t b);
+el_val_t el_duration_eq(el_val_t a, el_val_t b);
+el_val_t el_duration_ne(el_val_t a, el_val_t b);
+
+el_val_t instant_to_unix_seconds(el_val_t i);
+el_val_t instant_to_unix_millis(el_val_t i);
+el_val_t instant_to_iso8601(el_val_t i);
+el_val_t duration_to_seconds(el_val_t d);
+el_val_t duration_to_millis(el_val_t d);
+el_val_t duration_to_nanos(el_val_t d);
+
+el_val_t el_sleep_duration(el_val_t dur);
+el_val_t unix_timestamp(void);
+
+el_val_t ttl_cache_set(el_val_t key, el_val_t value);
+el_val_t ttl_cache_get(el_val_t key, el_val_t max_age);
+el_val_t ttl_cache_age(el_val_t key);
+
+/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ─────────────
+ * Phase 1.5 of the time system. Calendar is pluggable: EarthCalendar (IANA
+ * zones, Gregorian, DST) is the user-facing default; MarsCalendar,
+ * CycleCalendar(period), NoCycleCalendar, RelativeCalendar handle non-Earth
+ * domains.
+ *
+ * A Calendar interprets an Instant under a particular cycle convention and
+ * produces a CalendarTime. CalendarTime carries the underlying Instant and
+ * a back-pointer to its Calendar; arithmetic and formatting consult the
+ * Calendar to convert ns since epoch into year/month/day/hour/minute/second
+ * (or sol/phase, or cycle/phase, depending on kind).
+ *
+ * Storage convention: Calendar / CalendarTime / Rhythm / LocalDate /
+ * LocalDateTime are heap-allocated structs whose pointers are cast into
+ * el_val_t. A 24-bit magic header at offset 0 lets the runtime identify
+ * the kind safely. LocalTime is small enough to live in the int64 slot
+ * directly (nanos since midnight, signed). */
+
+/* Zone — opaque IANA zone or fixed offset, used by EarthCalendar.
+ * `zone_id` is either an IANA name ("America/New_York", "UTC") or a fixed
+ * offset string ("+05:30", "-08:00"). The runtime resolves it via tzset()
+ * on first use of the owning EarthCalendar. */
+el_val_t zone(el_val_t id);
+el_val_t zone_utc(void);
+el_val_t zone_local(void);
+el_val_t zone_offset(el_val_t hours, el_val_t minutes);
+
+/* Calendar constructors. Each returns an el_val_t pointer to a heap-
+ * allocated, magic-tagged Calendar struct. Calendars are interned by
+ * (kind, zone_id, period_ns, epoch_ns) so identical constructors return
+ * the same pointer — equality is reference equality. */
+el_val_t earth_calendar(el_val_t z);
+el_val_t earth_calendar_default(void);
+el_val_t mars_calendar(void);
+el_val_t cycle_calendar(el_val_t period_dur);
+el_val_t no_cycle_calendar(void);
+el_val_t relative_calendar(el_val_t epoch_inst);
+
+/* CalendarTime constructors and methods. Returns a heap-allocated struct
+ * whose pointer fits in el_val_t. */
+el_val_t now_in(el_val_t cal);
+el_val_t in_calendar(el_val_t inst, el_val_t cal);
+el_val_t cal_format(el_val_t ct, el_val_t pattern);
+el_val_t cal_to_instant(el_val_t ct);
+el_val_t cal_cycle_phase(el_val_t ct);
+el_val_t cal_in(el_val_t ct, el_val_t cal);
+
+/* LocalDate / LocalTime / LocalDateTime — calendar-agnostic value types.
+ * LocalTime carries nanoseconds since midnight as a signed int64 directly
+ * in the el_val_t slot (no allocation). LocalDate / LocalDateTime are
+ * heap-allocated structs with magic headers. */
+el_val_t local_date(el_val_t y, el_val_t m, el_val_t d);
+el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns);
+el_val_t local_datetime(el_val_t date, el_val_t time);
+el_val_t zoned(el_val_t date, el_val_t time, el_val_t cal);
+
+el_val_t local_date_year(el_val_t ld);
+el_val_t local_date_month(el_val_t ld);
+el_val_t local_date_day(el_val_t ld);
+el_val_t local_time_hour(el_val_t lt);
+el_val_t local_time_minute(el_val_t lt);
+el_val_t local_time_second(el_val_t lt);
+el_val_t local_time_nanos(el_val_t lt);
+
+el_val_t el_local_date_add_dur(el_val_t ld, el_val_t dur);
+el_val_t el_local_time_add_dur(el_val_t lt, el_val_t dur);
+el_val_t el_local_date_lt(el_val_t a, el_val_t b);
+el_val_t el_local_date_eq(el_val_t a, el_val_t b);
+
+/* Rhythm — pluggable recurrence AST. Returns a heap-allocated struct
+ * pointer in el_val_t; rhythms are immutable so callers may share them. */
+el_val_t rhythm_cycle_start(void);
+el_val_t rhythm_cycle_phase(el_val_t phase);
+el_val_t rhythm_duration(el_val_t d);
+el_val_t rhythm_session_start(void);
+el_val_t rhythm_event(el_val_t name);
+el_val_t rhythm_and(el_val_t a, el_val_t b);
+el_val_t rhythm_or(el_val_t a, el_val_t b);
+el_val_t rhythm_weekday(el_val_t day);
+el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute);
+el_val_t rhythm_next_after(el_val_t r, el_val_t after, el_val_t cal);
+el_val_t rhythm_matches(el_val_t r, el_val_t ct);
+
+/* ── UUID ────────────────────────────────────────────────────────────────── */
+
+el_val_t uuid_new(void);
+el_val_t uuid_v4(void);
+
+/* ── Environment ─────────────────────────────────────────────────────────── */
+
+el_val_t env(el_val_t key);
+
+/* ── In-process state K/V ────────────────────────────────────────────────── */
+
+el_val_t state_set(el_val_t key, el_val_t value);
+el_val_t state_get(el_val_t key);
+el_val_t state_del(el_val_t key);
+el_val_t state_keys(void);
+el_val_t state_has(el_val_t key);
+el_val_t state_get_or(el_val_t key, el_val_t default_val);
+
+/* ── Float formatting ────────────────────────────────────────────────────── */
+
+el_val_t float_to_str(el_val_t f);
+el_val_t int_to_float(el_val_t n);
+el_val_t float_to_int(el_val_t f);
+el_val_t format_float(el_val_t f, el_val_t decimals);
+el_val_t decimal_round(el_val_t f, el_val_t decimals);
+el_val_t str_to_float(el_val_t s);
+
+/* ── Math (Float-aware) ──────────────────────────────────────────────────── */
+
+el_val_t math_sqrt(el_val_t f);
+el_val_t math_log(el_val_t f);
+el_val_t math_ln(el_val_t f);
+el_val_t math_sin(el_val_t f);
+el_val_t math_cos(el_val_t f);
+el_val_t math_pi(void);
+
+/* ── String additions ────────────────────────────────────────────────────── */
+
+el_val_t str_index_of(el_val_t s, el_val_t sub);
+el_val_t str_split(el_val_t s, el_val_t sep);
+el_val_t str_char_at(el_val_t s, el_val_t i);
+el_val_t str_char_code(el_val_t s, el_val_t i);
+el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad);
+el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad);
+el_val_t str_format(el_val_t fmt, el_val_t data);
+el_val_t str_lower(el_val_t s);
+el_val_t str_upper(el_val_t s);
+
+/* ── Text-processing primitives (Phase 1: byte/codepoint, ASCII char classes)
+ * Phase 2 (filed): Unicode-grapheme awareness, NFC/NFD normalization, regex.
+ * is_* predicates: empty input returns false; multi-char requires ALL bytes
+ * to match. ASCII ranges only in Phase 1. */
+
+/* Counting */
+el_val_t str_count(el_val_t s, el_val_t sub); /* non-overlapping */
+el_val_t str_count_chars(el_val_t s); /* codepoint count */
+el_val_t str_count_bytes(el_val_t s); /* alias of str_len */
+el_val_t str_count_lines(el_val_t s);
+el_val_t str_count_words(el_val_t s);
+el_val_t str_count_letters(el_val_t s); /* ASCII [A-Za-z] */
+el_val_t str_count_digits(el_val_t s); /* ASCII [0-9] */
+
+/* Find / position */
+el_val_t str_index_of_all(el_val_t s, el_val_t sub); /* [Int] of byte offsets */
+el_val_t str_last_index_of(el_val_t s, el_val_t sub);
+el_val_t str_find_chars(el_val_t s, el_val_t any_of); /* first idx of any ch */
+
+/* Transform */
+el_val_t str_repeat(el_val_t s, el_val_t n);
+el_val_t str_reverse(el_val_t s); /* by codepoint */
+el_val_t str_strip_prefix(el_val_t s, el_val_t prefix);
+el_val_t str_strip_suffix(el_val_t s, el_val_t suffix);
+el_val_t str_strip_chars(el_val_t s, el_val_t chars);
+el_val_t str_lstrip(el_val_t s);
+el_val_t str_rstrip(el_val_t s);
+
+/* Char classification (Bool) */
+el_val_t is_letter(el_val_t s);
+el_val_t is_digit(el_val_t s);
+el_val_t is_alphanumeric(el_val_t s);
+el_val_t is_whitespace(el_val_t s);
+el_val_t is_punctuation(el_val_t s);
+el_val_t is_uppercase(el_val_t s);
+el_val_t is_lowercase(el_val_t s);
+
+/* Split / join */
+el_val_t str_split_lines(el_val_t s);
+el_val_t str_split_chars(el_val_t s); /* alias of native_string_chars */
+el_val_t str_split_n(el_val_t s, el_val_t sep, el_val_t n);
+el_val_t str_join(el_val_t list, el_val_t sep); /* alias of list_join */
+
+/* ── List additions ──────────────────────────────────────────────────────── */
+
+el_val_t list_push(el_val_t list, el_val_t elem);
+el_val_t list_push_front(el_val_t list, el_val_t elem);
+el_val_t list_join(el_val_t list, el_val_t sep);
+el_val_t list_range(el_val_t start, el_val_t end);
+
+/* ── Bool helpers ────────────────────────────────────────────────────────── */
+
+el_val_t bool_to_str(el_val_t b);
+
+/* ── Numeric parsing ─────────────────────────────────────────────────────── */
+
+el_val_t parse_int(el_val_t s, el_val_t default_val);
+
+/* ── Process ─────────────────────────────────────────────────────────────── */
+
+el_val_t exit_program(el_val_t code);
+el_val_t getpid_now(void);
+
+/* Self-terminating memory guard. Reads ELC_MAX_MEM_MB (default 512) and
+ * exits with code 1 if resident memory exceeds the limit. Call periodically
+ * during long compilation loops (e.g. after each function is compiled).
+ * Returns 0 when memory is within bounds. */
+el_val_t el_mem_check(void);
+
+/* ── CGI identity ─────────────────────────────────────────────────────────────
+ * Called at the start of main() in CGI programs (those with a `cgi {}` block).
+ * Records the program's DHARMA identity before any other code executes. */
+
+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);
+
+/* ── DHARMA network builtins ─────────────────────────────────────────────────
+ * Available to CGI programs (declared with a `cgi {}` block).
+ *
+ * Peers are addressed by `dharma_id` of the form
+ * "@" e.g. "ntn-genesis@http://localhost:7770"
+ * If the @ portion is omitted, transport defaults to
+ * "http://localhost:7770" (the local CGI daemon assumption).
+ *
+ * Wire protocol (all peers expose):
+ * POST /dharma/recv { channel, from, content } → response body
+ * POST /dharma/event { type, payload, source, timestamp }
+ * POST /api/activate { query } → list of nodes
+ *
+ * Hosting application's responsibility: an El program with a `cgi {}` block
+ * runs http_serve() with its own request handler; that handler should route
+ * "/dharma/event" requests by calling el_runtime_dharma_event_arrive() so
+ * incoming events feed dharma_field() queues. The runtime itself does not
+ * intercept any /dharma path. */
+
+el_val_t dharma_connect(el_val_t cgi_id);
+el_val_t dharma_send(el_val_t channel, el_val_t content);
+el_val_t dharma_activate(el_val_t query);
+void dharma_emit(el_val_t event_type, el_val_t payload);
+el_val_t dharma_field(el_val_t event_type);
+void dharma_strengthen(el_val_t cgi_id, el_val_t weight);
+el_val_t dharma_relationship(el_val_t cgi_id);
+el_val_t dharma_peers(void);
+
+/* Public C API: called by an El program's HTTP handler when a /dharma/event
+ * request arrives. Pushes onto the per-event-type queue and signals any
+ * pending dharma_field() blockers. All three arguments must be NUL-terminated
+ * C strings (or NULL — then treated as empty). */
+void el_runtime_dharma_event_arrive(const char* event_type,
+ const char* payload,
+ const char* source);
+
+/* ── Engram local graph primitives ───────────────────────────────────────────
+ * Operate on the CGI's local Engram knowledge graph.
+ * `engram_activate` queries the local graph only; `dharma_activate` is
+ * network-wide across all connected CGI graphs. */
+
+el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience);
+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);
+/* Layered consciousness — see el_runtime.c for the layered architecture
+ * design notes (search "Layered consciousness architecture"). The five
+ * canonical layers (safety / core-identity / domain-knowledge / imprint /
+ * suit) are seeded automatically; engram_add_layer extends the registry
+ * with imprint or suit overlays at runtime. Nodes default to layer 1
+ * (core-identity) when created via engram_node / engram_node_full. */
+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);
+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);
+el_val_t engram_remove_layer(el_val_t layer_id);
+el_val_t engram_list_layers(void);
+el_val_t engram_get_node(el_val_t id);
+void engram_strengthen(el_val_t node_id);
+void engram_forget(el_val_t node_id);
+el_val_t engram_node_count(void);
+el_val_t engram_search(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
+void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
+el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id);
+el_val_t engram_neighbors(el_val_t node_id);
+el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_edge_count(void);
+/* Three-pass activation: background fan-out → working-memory promotion →
+ * Layer 0 override. See "Three-pass activation" in el_runtime.c. */
+el_val_t engram_activate(el_val_t query, el_val_t depth);
+el_val_t engram_save(el_val_t path);
+el_val_t engram_load(el_val_t path);
+
+/* JSON-string accessors — return pre-serialized JSON so HTTP handlers
+ * can pass results straight through without round-tripping ElList/ElMap
+ * through json_stringify. */
+el_val_t engram_get_node_json(el_val_t id);
+el_val_t engram_search_json(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
+el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_activate_json(el_val_t query, el_val_t depth);
+el_val_t engram_stats_json(void);
+el_val_t engram_list_layers_json(void);
+/* engram_compile_layered_json — produce a prompt-ready text block split
+ * into "[LAYER 0 — STRUCTURAL]" (non-suppressible layers, sacred fire)
+ * and "[ENGRAM CONTEXT]" (standard suppressible layers). Returns "" if
+ * no nodes promoted to working memory. */
+el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth);
+
+/* ── LLM (Anthropic API client) ─────────────────────────────────────────────
+ * All functions call https://api.anthropic.com/v1/messages with the API key
+ * from env ANTHROPIC_API_KEY. Default model when empty: claude-sonnet-4-5. */
+
+el_val_t llm_call(el_val_t model, el_val_t prompt);
+el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt);
+el_val_t llm_call_agentic(el_val_t model, el_val_t system, el_val_t user, el_val_t tools);
+el_val_t llm_vision(el_val_t model, el_val_t system, el_val_t prompt, el_val_t image_url_or_b64);
+el_val_t llm_models(void);
+
+/* Register a tool handler by name. The handler is looked up via dlsym
+ * (mirroring http_set_handler), so any El `fn (input)` compiles to
+ * a global C symbol that this function can locate at runtime.
+ * Handler signature: `el_val_t handler(el_val_t input_json)` — receives
+ * the tool input as a JSON-string el_val_t and returns a JSON-string
+ * el_val_t result. Used by llm_call_agentic. */
+void llm_register_tool(el_val_t name, el_val_t handler_fn_name);
+
+/* ── args() ─────────────────────────────────────────────────────────────────
+ * Provides access to command-line arguments passed to the program.
+ * Populated by el_runtime_init_args() before main() runs. */
+
+el_val_t args(void);
+void el_runtime_init_args(int argc, char** argv);
+
+/* ── Crypto primitives ─────────────────────────────────────────────────────
+ * SHA-256, HMAC-SHA-256, and base64 (standard + URL-safe).
+ * Self-contained — no OpenSSL/libcrypto dependency. The implementations are
+ * adapted from public-domain reference code (Brad Conte / RFC 4648).
+ *
+ * Bytes-returning variants (sha256_bytes, hmac_sha256_bytes) return a string
+ * value whose contents are raw binary; callers usually feed these into
+ * base64_encode. Note that el_val_t strings are NUL-terminated by convention,
+ * so the binary payload may contain embedded NULs — pass it directly into
+ * base64_encode (which uses an explicit length) rather than treating it as
+ * a printable C string.
+ *
+ * The "base64" variants emit/accept RFC 4648 standard alphabet with padding.
+ * The "base64url" variants use URL-safe alphabet (`-`/`_`) with no padding,
+ * as used in JWTs. */
+
+el_val_t sha256_hex(el_val_t input);
+el_val_t sha256_bytes(el_val_t input);
+el_val_t hmac_sha256_hex(el_val_t key, el_val_t message);
+el_val_t hmac_sha256_bytes(el_val_t key, el_val_t message);
+el_val_t base64_encode(el_val_t input);
+el_val_t base64_decode(el_val_t input);
+el_val_t base64url_encode(el_val_t input);
+el_val_t base64url_decode(el_val_t input);
+
+/* Length-aware variants (internal — exposed for the rare caller that already
+ * has a known-length binary buffer and doesn't want to round-trip through
+ * a NUL-terminated el_val_t string). Sha256_bytes and hmac_sha256_bytes feed
+ * these implicitly. */
+el_val_t el_sha256_bytes_n(const unsigned char* data, size_t len);
+el_val_t el_base64_encode_n(const unsigned char* data, size_t len, int url_safe);
+
+/* ── Post-quantum primitives (liboqs-backed) ────────────────────────────────
+ * All inputs/outputs hex-encoded. Algorithm choices:
+ * Signature: CRYSTALS-Dilithium-3 (NIST level 3, balanced)
+ * KEM: CRYSTALS-Kyber-768 (NIST level 3)
+ * Hash: SHA3-256 (Keccak) (PQ-aware protocols favour SHA3 over SHA2)
+ *
+ * If liboqs is not linked (detected via __has_include() at compile
+ * time), the pq_* entry points return a JSON-shaped error string so callers
+ * fail loudly rather than silently fall back to classical schemes:
+ * {"error":"liboqs not linked, post-quantum primitives unavailable"}
+ *
+ * The hybrid handshake pairs X25519 with Kyber-768 per NIST PQ guidance and
+ * CNSA 2.0. Combined shared secret is HKDF-SHA256(x25519_ss || kyber_ss).
+ * Even if Kyber falls, X25519 holds; if X25519 falls under quantum attack,
+ * Kyber holds. SHA3-256 also remains usable independent of liboqs (the
+ * Keccak permutation is PQ-OK as a primitive). */
+
+el_val_t pq_keygen_signature(void);
+el_val_t pq_sign(el_val_t secret_key_hex, el_val_t message);
+el_val_t pq_verify(el_val_t public_key_hex, el_val_t message, el_val_t signature_hex);
+
+el_val_t pq_kem_keygen(void);
+el_val_t pq_kem_encaps(el_val_t public_key_hex);
+el_val_t pq_kem_decaps(el_val_t secret_key_hex, el_val_t ciphertext_hex);
+
+el_val_t pq_hybrid_keygen(void);
+el_val_t pq_hybrid_handshake(el_val_t remote_pub_combined);
+
+el_val_t sha3_256_hex(el_val_t input);
+
+/* ── AEAD: AES-256-GCM (libcrypto-backed) ───────────────────────────────────
+ * Symmetric authenticated encryption used to wrap envelopes after a KEM
+ * handshake. Caller MUST supply a 32-byte key (64 hex chars) — typically the
+ * Kyber-768 / hybrid shared_secret, optionally normalized via SHA3-256.
+ *
+ * aead_encrypt returns a JSON map {"nonce":"...","ciphertext":"..."} where
+ * ciphertext is the AES-256-GCM output with the 16-byte auth tag appended.
+ * Nonce is a fresh 12-byte CSPRNG draw — callers never pick the nonce, which
+ * structurally rules out the GCM nonce-reuse footgun.
+ *
+ * aead_decrypt returns the plaintext String, or "" on any failure (including
+ * auth-tag mismatch). Callers MUST check for "" before trusting the result. */
+el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext);
+el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex);
+
+/* ── Native VM builtin aliases (for compiled El source) ─────────────────────
+ * These match the El VM's native_* builtins so that El source compiled
+ * to C can call the same names without modification. */
+
+el_val_t native_list_get(el_val_t list, el_val_t index);
+el_val_t native_list_len(el_val_t list);
+el_val_t native_list_append(el_val_t list, el_val_t elem);
+el_val_t native_list_empty(void);
+el_val_t native_list_clone(el_val_t list);
+el_val_t native_string_chars(el_val_t s);
+el_val_t native_int_to_str(el_val_t n);
+
+/* ── Method-call shorthand aliases ──────────────────────────────────────────
+ * The El method-call convention `obj.method(args)` compiles to
+ * `method(obj, args)`. These aliases expose the runtime functions under
+ * the short names that result from method calls in El source.
+ *
+ * Example: `myList.append(x)` → `append(myList, x)` (calls this alias)
+ * `myList.len()` → `len(myList)` (calls this alias) */
+
+el_val_t append(el_val_t list, el_val_t elem); /* el_list_append */
+el_val_t len(el_val_t list); /* el_list_len */
+el_val_t get(el_val_t list, el_val_t index); /* el_list_get */
+el_val_t map_get(el_val_t map, el_val_t key); /* el_map_get */
+el_val_t map_set(el_val_t map, el_val_t key, el_val_t value); /* el_map_set */
+
+/* ── OTLP/HTTP Observability ─────────────────────────────────────────────── */
+/* See bottom of el_runtime.c for the implementation.
+ * Configured by env vars OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_SERVICE_VERSION.
+ * No-op when OTLP_ENDPOINT is unset. Drop-on-failure semantics. */
+/* ── Subprocess execution ────────────────────────────────────────────────── */
+el_val_t exec_command(el_val_t cmd); /* run shell command, return exit code */
+el_val_t exec_capture(el_val_t cmd); /* run shell command, capture stdout */
+el_val_t exec(el_val_t cmd); /* exec(cmd) → stdout String (30s timeout) */
+el_val_t exec_bg(el_val_t cmd); /* exec_bg(cmd) → PID String (non-blocking) */
+
+/* ── Stdout redirection (used by compiler JS pipeline) ───────────────────── */
+el_val_t stdout_to_file(el_val_t path); /* redirect process stdout to a file */
+el_val_t stdout_restore(void); /* restore process stdout to terminal */
+
+el_val_t emit_log(el_val_t level, el_val_t msg, el_val_t fields_json);
+el_val_t emit_metric(el_val_t name, el_val_t value, el_val_t tags_json);
+el_val_t trace_span_start(el_val_t name);
+el_val_t trace_span_end(el_val_t span_handle);
+el_val_t emit_event(el_val_t name, el_val_t duration_ms);
+
+el_val_t __thread_create(el_val_t fn_name_v, el_val_t arg_v);
+el_val_t __thread_join(el_val_t tid_v);
+
+/* ── __ prefixed aliases (self-hosting compiler ABI) ─────────────────────────
+ * The El self-hosting compiler emits calls to __-prefixed names. These are
+ * forwarding wrappers around the existing el_runtime functions above. */
+
+/* I/O */
+el_val_t __println(el_val_t s);
+el_val_t __print(el_val_t s);
+el_val_t __readline(void);
+
+/* String */
+el_val_t __int_to_str(el_val_t n);
+el_val_t __str_to_int(el_val_t s);
+el_val_t __float_to_str(el_val_t f);
+el_val_t __str_to_float(el_val_t s);
+el_val_t __str_len(el_val_t s);
+el_val_t __str_char_at(el_val_t s, el_val_t i);
+el_val_t __str_cmp(el_val_t a, el_val_t b);
+el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n);
+el_val_t __str_concat_raw(el_val_t a, el_val_t b);
+el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end);
+el_val_t __str_alloc(el_val_t n);
+el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c);
+
+/* URL encoding */
+el_val_t __url_encode(el_val_t s);
+el_val_t __url_decode(el_val_t s);
+
+/* Environment */
+el_val_t __env_get(el_val_t key);
+
+/* Subprocess */
+el_val_t __exec(el_val_t cmd);
+el_val_t __exec_bg(el_val_t cmd);
+
+/* Process */
+el_val_t __exit_program(el_val_t code);
+
+/* Filesystem */
+el_val_t __fs_exists(el_val_t path);
+el_val_t __fs_mkdir(el_val_t path);
+el_val_t __fs_read(el_val_t path);
+el_val_t __fs_write(el_val_t path, el_val_t content);
+el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n);
+el_val_t __fs_list_raw(el_val_t path);
+
+/* HTTP server */
+el_val_t __http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+el_val_t __http_serve(el_val_t port, el_val_t handler);
+el_val_t __http_serve_v2(el_val_t port, el_val_t handler);
+
+/* HTTP conn fd / SSE (weak; overridden by el_seed.c when linked together) */
+el_val_t __http_conn_fd(void);
+el_val_t __http_sse_open(el_val_t conn_id);
+el_val_t __http_sse_send(el_val_t conn_id, el_val_t data);
+el_val_t __http_sse_close(el_val_t conn_id);
+
+/* HTTP client (requires HAVE_CURL; stubs provided for no-curl builds) */
+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);
+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);
+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);
+
+/* JSON */
+el_val_t __json_array_get(el_val_t json, el_val_t index);
+el_val_t __json_array_get_string(el_val_t json, el_val_t index);
+el_val_t __json_array_len(el_val_t json);
+el_val_t __json_get(el_val_t json, el_val_t key);
+el_val_t __json_get_raw(el_val_t json, el_val_t key);
+el_val_t __json_set(el_val_t json, el_val_t key, el_val_t value);
+el_val_t __json_parse_map(el_val_t json_str);
+el_val_t __json_stringify_val(el_val_t val);
+
+/* Hashing */
+el_val_t __sha256_hex(el_val_t s);
+
+/* State K/V */
+el_val_t __state_del(el_val_t key);
+el_val_t __state_get(el_val_t key);
+el_val_t __state_keys(void);
+el_val_t __state_set(el_val_t key, el_val_t val);
+
+/* UUID */
+el_val_t __uuid_v4(void);
+
+/* Args */
+el_val_t __args_json(void);
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/ui/examples/native-hello-ios/NativeHello/el_seed.c b/ui/examples/native-hello-ios/NativeHello/el_seed.c
new file mode 100644
index 0000000..fc9fbba
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_seed.c
@@ -0,0 +1,1942 @@
+/*
+ * el_seed.c — El language seed runtime: minimal C OS boundary
+ *
+ * This file exposes all OS-boundary primitives that El programs need, under
+ * the __ prefix convention. It is self-contained: all allocators, arena
+ * management, and el_request_start / el_request_end are defined here.
+ *
+ * Threading: __thread_create / __thread_join use dlsym(RTLD_DEFAULT) to look
+ * up El function symbols at runtime. This is the foundation of El's parallelism.
+ *
+ * Link: cc -std=c11 -I el-compiler/runtime -lcurl -lpthread \
+ * -o .c el_seed.c
+ */
+
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include "el_seed.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* ── Private allocator ───────────────────────────────────────────────────── */
+/*
+ * el_seed.c carries its own arena for per-request allocation tracking.
+ * The arena is reset at el_request_start / el_request_end, which are defined
+ * below and delegate to seed_request_start / seed_request_end.
+ */
+
+#define SEED_ARENA_INITIAL 512
+
+typedef struct {
+ char** ptrs;
+ size_t count;
+ size_t cap;
+} SeedArena;
+
+static _Thread_local SeedArena _seed_arena = {NULL, 0, 0};
+static _Thread_local int _seed_arena_on = 0;
+
+static void seed_arena_track(char* p) {
+ if (!_seed_arena_on || !p) return;
+ if (_seed_arena.count >= _seed_arena.cap) {
+ size_t nc = _seed_arena.cap == 0 ? SEED_ARENA_INITIAL : _seed_arena.cap * 2;
+ char** g = realloc(_seed_arena.ptrs, nc * sizeof(char*));
+ if (!g) return;
+ _seed_arena.ptrs = g;
+ _seed_arena.cap = nc;
+ }
+ _seed_arena.ptrs[_seed_arena.count++] = p;
+}
+
+static void seed_request_start(void) {
+ _seed_arena.count = 0;
+ _seed_arena_on = 1;
+}
+
+static void seed_request_end(void) {
+ _seed_arena_on = 0;
+ for (size_t i = 0; i < _seed_arena.count; i++) free(_seed_arena.ptrs[i]);
+ _seed_arena.count = 0;
+}
+
+/* el_request_start / el_request_end — formerly defined in el_runtime.c.
+ * Now self-contained in el_seed.c, delegating to the seed arena. */
+void el_request_start(void) { seed_request_start(); }
+void el_request_end(void) { seed_request_end(); }
+
+/* Persistent alloc — bypasses arena (state, engram internals). */
+static char* seed_strdup_persist(const char* s) {
+ if (!s) return strdup("");
+ return strdup(s);
+}
+
+static char* seed_strdup(const char* s) {
+ char* p = strdup(s ? s : "");
+ seed_arena_track(p);
+ return p;
+}
+
+static char* seed_strbuf(size_t n) {
+ char* p = malloc(n + 1);
+ if (!p) { fputs("el_seed: out of memory\n", stderr); exit(1); }
+ p[0] = '\0';
+ seed_arena_track(p);
+ return p;
+}
+
+static el_val_t seed_wrap_str(char* s) { return EL_STR(s); }
+
+/* ── String primitives ───────────────────────────────────────────────────── */
+
+el_val_t __str_len(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ return (el_val_t)strlen(p);
+}
+
+el_val_t __str_char_at(el_val_t s, el_val_t i) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ int64_t len = (int64_t)strlen(p);
+ int64_t idx = (int64_t)i;
+ if (idx < 0 || idx >= len) return 0;
+ return (el_val_t)(unsigned char)p[idx];
+}
+
+el_val_t __str_alloc(el_val_t n) {
+ int64_t sz = (int64_t)n;
+ if (sz < 0) sz = 0;
+ char* buf = seed_strbuf((size_t)sz);
+ memset(buf, 0, (size_t)sz + 1);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __str_set_char(el_val_t s, el_val_t i, el_val_t c) {
+ char* p = (char*)(uintptr_t)s;
+ if (!p) return s;
+ int64_t len = (int64_t)strlen(p);
+ int64_t idx = (int64_t)i;
+ if (idx < 0 || idx >= len) return s;
+ p[idx] = (char)(unsigned char)(int64_t)c;
+ return s;
+}
+
+el_val_t __str_cmp(el_val_t a, el_val_t b) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ return (el_val_t)strcmp(sa, sb);
+}
+
+el_val_t __str_ncmp(el_val_t a, el_val_t b, el_val_t n) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ return (el_val_t)strncmp(sa, sb, (size_t)(int64_t)n);
+}
+
+el_val_t __str_concat_raw(el_val_t a, el_val_t b) {
+ const char* sa = EL_CSTR(a);
+ const char* sb = EL_CSTR(b);
+ if (!sa) sa = "";
+ if (!sb) sb = "";
+ size_t la = strlen(sa), lb = strlen(sb);
+ char* out = seed_strbuf(la + lb);
+ memcpy(out, sa, la);
+ memcpy(out + la, sb, lb);
+ out[la + lb] = '\0';
+ return seed_wrap_str(out);
+}
+
+el_val_t __str_slice_raw(el_val_t s, el_val_t start, el_val_t end) {
+ const char* p = EL_CSTR(s);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ int64_t len = (int64_t)strlen(p);
+ int64_t st = (int64_t)start;
+ int64_t en = (int64_t)end;
+ if (st < 0) st = 0;
+ if (en > len) en = len;
+ if (st >= en) return seed_wrap_str(seed_strdup(""));
+ int64_t sz = en - st;
+ char* out = seed_strbuf((size_t)sz);
+ memcpy(out, p + st, (size_t)sz);
+ out[sz] = '\0';
+ return seed_wrap_str(out);
+}
+
+el_val_t __int_to_str(el_val_t n) {
+ char buf[32];
+ snprintf(buf, sizeof(buf), "%lld", (long long)(int64_t)n);
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+el_val_t __str_to_int(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return 0;
+ return (el_val_t)atoll(p);
+}
+
+el_val_t __float_to_str(el_val_t f) {
+ char buf[64];
+ snprintf(buf, sizeof(buf), "%g", el_to_float(f));
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+el_val_t __str_to_float(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (!p) return el_from_float(0.0);
+ return el_from_float(strtod(p, NULL));
+}
+
+/* ── I/O ─────────────────────────────────────────────────────────────────── */
+
+void __println(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ puts(p ? p : "");
+}
+
+void __print(el_val_t s) {
+ const char* p = EL_CSTR(s);
+ if (p) fputs(p, stdout);
+}
+
+el_val_t __readline(void) {
+ char buf[4096];
+ if (!fgets(buf, sizeof(buf), stdin)) return seed_wrap_str(seed_strdup(""));
+ size_t len = strlen(buf);
+ if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
+ return seed_wrap_str(seed_strdup(buf));
+}
+
+/* ── Filesystem ──────────────────────────────────────────────────────────── */
+
+el_val_t __fs_read(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ FILE* f = fopen(p, "rb");
+ if (!f) return seed_wrap_str(seed_strdup(""));
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ rewind(f);
+ if (sz < 0) { fclose(f); return seed_wrap_str(seed_strdup("")); }
+ char* buf = seed_strbuf((size_t)sz);
+ size_t got = fread(buf, 1, (size_t)sz, f);
+ buf[got] = '\0';
+ fclose(f);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __fs_write(el_val_t path, el_val_t content) {
+ const char* p = EL_CSTR(path);
+ const char* c = EL_CSTR(content);
+ if (!p || !c) return 0;
+ FILE* f = fopen(p, "wb");
+ if (!f) return 0;
+ size_t n = strlen(c);
+ size_t w = fwrite(c, 1, n, f);
+ fclose(f);
+ return w == n ? 1 : 0;
+}
+
+el_val_t __fs_exists(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return 0;
+ struct stat st;
+ return (el_val_t)(stat(p, &st) == 0 ? 1 : 0);
+}
+
+el_val_t __fs_list_raw(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p) return seed_wrap_str(seed_strdup(""));
+ DIR* d = opendir(p);
+ if (!d) return seed_wrap_str(seed_strdup(""));
+ /* Build newline-separated list of filenames. */
+ size_t cap = 4096, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) { closedir(d); return seed_wrap_str(seed_strdup("")); }
+ buf[0] = '\0';
+ struct dirent* e;
+ while ((e = readdir(d)) != NULL) {
+ if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
+ size_t nlen = strlen(e->d_name);
+ while (len + nlen + 2 >= cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); closedir(d); return seed_wrap_str(seed_strdup("")); }
+ buf = g;
+ }
+ if (len > 0) buf[len++] = '\n';
+ memcpy(buf + len, e->d_name, nlen);
+ len += nlen;
+ buf[len] = '\0';
+ }
+ closedir(d);
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+el_val_t __fs_mkdir(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return 0;
+ size_t n = strlen(p);
+ char* buf = malloc(n + 1);
+ if (!buf) return 0;
+ memcpy(buf, p, n + 1);
+ 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) {
+ struct stat st;
+ if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
+ free(buf); return 0;
+ }
+ }
+ }
+ buf[i] = saved;
+ }
+ }
+ free(buf);
+ return 1;
+}
+
+el_val_t __fs_write_bytes(el_val_t path, el_val_t bytes, el_val_t n) {
+ const char* p = EL_CSTR(path);
+ const char* b = EL_CSTR(bytes);
+ int64_t sz = (int64_t)n;
+ if (!p || !b || sz < 0) return 0;
+ FILE* f = fopen(p, "wb");
+ if (!f) return 0;
+ size_t written = (sz > 0) ? fwrite(b, 1, (size_t)sz, f) : 0;
+ int ok1 = (fflush(f) == 0);
+ int ok2 = (fclose(f) == 0);
+ if (!ok1 || !ok2 || written != (size_t)sz) { remove(p); return 0; }
+ return 1;
+}
+
+/* ── HTTP client ─────────────────────────────────────────────────────────── */
+
+typedef struct {
+ char* data;
+ size_t len;
+ size_t cap;
+} SeedHttpBuf;
+
+static void seed_httpbuf_init(SeedHttpBuf* b) {
+ b->cap = 1024; b->len = 0;
+ b->data = malloc(b->cap);
+ if (!b->data) { fputs("el_seed: out of memory\n", stderr); exit(1); }
+ b->data[0] = '\0';
+}
+
+static void seed_httpbuf_append(SeedHttpBuf* b, const void* src, size_t n) {
+ while (b->len + n + 1 > b->cap) b->cap *= 2;
+ b->data = realloc(b->data, b->cap);
+ if (!b->data) { fputs("el_seed: 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 seed_http_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
+ size_t n = size * nmemb;
+ seed_httpbuf_append((SeedHttpBuf*)ud, ptr, n);
+ return n;
+}
+
+/* Build a curl_slist from a JSON object string of header name:value pairs. */
+static struct curl_slist* seed_headers_from_json(const char* hj) {
+ struct curl_slist* h = NULL;
+ if (!hj || !*hj) return NULL;
+ /* Walk key:value pairs at depth 1. Simple parser — same logic as json_find_key
+ * in el_runtime.c but adapted for building curl headers. */
+ const char* p = hj;
+ while (*p && *p != '{') p++;
+ if (*p == '{') p++;
+ while (*p) {
+ while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
+ if (*p == '}' || *p == '\0') break;
+ if (*p != '"') break;
+ /* Parse key */
+ p++;
+ const char* ks = p;
+ while (*p && *p != '"') { if (*p == '\\') p++; p++; }
+ size_t klen = (size_t)(p - ks);
+ if (*p == '"') p++;
+ /* Skip : */
+ while (*p == ' ' || *p == ':') p++;
+ /* Parse value */
+ if (*p != '"') break;
+ p++;
+ const char* vs = p;
+ while (*p && *p != '"') { if (*p == '\\') p++; p++; }
+ size_t vlen = (size_t)(p - vs);
+ if (*p == '"') p++;
+ /* Build "Key: Value" header line */
+ size_t line_len = klen + 2 + vlen + 1;
+ char* line = malloc(line_len);
+ if (line) {
+ memcpy(line, ks, klen);
+ memcpy(line + klen, ": ", 2);
+ memcpy(line + klen + 2, vs, vlen);
+ line[klen + 2 + vlen] = '\0';
+ h = curl_slist_append(h, line);
+ free(line);
+ }
+ }
+ return h;
+}
+
+static el_val_t seed_http_error_json(const char* msg) {
+ if (!msg) msg = "unknown error";
+ size_t n = strlen(msg) * 6 + 20;
+ char* buf = seed_strbuf(n);
+ /* Simple escape: replace " with \" */
+ char* d = buf;
+ *d++ = '{'; *d++ = '"'; *d++ = 'e'; *d++ = 'r'; *d++ = 'r';
+ *d++ = 'o'; *d++ = 'r'; *d++ = '"'; *d++ = ':'; *d++ = '"';
+ for (const char* s = msg; *s; s++) {
+ if (*s == '"' || *s == '\\') *d++ = '\\';
+ *d++ = *s;
+ }
+ *d++ = '"'; *d++ = '}'; *d = '\0';
+ return seed_wrap_str(buf);
+}
+
+el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
+ el_val_t headers_json, el_val_t timeout_ms) {
+ const char* m = EL_CSTR(method);
+ const char* u = EL_CSTR(url);
+ const char* b = EL_CSTR(body);
+ const char* hj = EL_CSTR(headers_json);
+ int64_t tms = (int64_t)timeout_ms;
+ if (tms <= 0) tms = 60000;
+
+ if (!u || !*u) return seed_http_error_json("empty url");
+
+ CURL* c = curl_easy_init();
+ if (!c) return seed_http_error_json("curl_easy_init failed");
+
+ SeedHttpBuf rb; seed_httpbuf_init(&rb);
+ char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
+
+ curl_easy_setopt(c, CURLOPT_URL, u);
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, seed_http_write_cb);
+ curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
+ curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
+ curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, (long)tms);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
+ curl_easy_setopt(c, CURLOPT_USERAGENT, "el-seed/1.0");
+
+ struct curl_slist* hdrs = seed_headers_from_json(hj);
+ if (hdrs) curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
+
+ if (m && strcmp(m, "POST") == 0) {
+ curl_easy_setopt(c, CURLOPT_POST, 1L);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b ? b : "");
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(b ? strlen(b) : 0));
+ } else if (m && strcmp(m, "PUT") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PUT");
+ if (b) {
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(b));
+ }
+ } else if (m && strcmp(m, "DELETE") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
+ } else if (m && strcmp(m, "PATCH") == 0) {
+ curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH");
+ if (b) {
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(b));
+ }
+ }
+ /* GET is the default */
+
+ CURLcode rc = curl_easy_perform(c);
+ if (hdrs) curl_slist_free_all(hdrs);
+ curl_easy_cleanup(c);
+
+ if (rc != CURLE_OK) {
+ free(rb.data);
+ const char* em = errbuf[0] ? errbuf : curl_easy_strerror(rc);
+ return seed_http_error_json(em);
+ }
+
+ seed_arena_track(rb.data);
+ return seed_wrap_str(rb.data);
+}
+
+static size_t seed_http_file_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
+ return fwrite(ptr, size, nmemb, (FILE*)ud);
+}
+
+el_val_t __http_do_to_file(el_val_t method, el_val_t url, el_val_t body,
+ el_val_t headers_json, el_val_t out_path) {
+ const char* m = EL_CSTR(method);
+ const char* u = EL_CSTR(url);
+ const char* b = EL_CSTR(body);
+ const char* hj = EL_CSTR(headers_json);
+ const char* op = EL_CSTR(out_path);
+
+ if (!u || !*u || !op || !*op) return 0;
+ FILE* f = fopen(op, "wb");
+ if (!f) return 0;
+
+ CURL* c = curl_easy_init();
+ if (!c) { fclose(f); remove(op); return 0; }
+
+ char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
+ curl_easy_setopt(c, CURLOPT_URL, u);
+ curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, seed_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, 60000L);
+ curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
+ curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
+ curl_easy_setopt(c, CURLOPT_USERAGENT, "el-seed/1.0");
+
+ struct curl_slist* hdrs = seed_headers_from_json(hj);
+ if (hdrs) curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
+
+ if (m && strcmp(m, "POST") == 0) {
+ curl_easy_setopt(c, CURLOPT_POST, 1L);
+ curl_easy_setopt(c, CURLOPT_POSTFIELDS, b ? b : "");
+ curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(b ? strlen(b) : 0));
+ }
+
+ CURLcode rc = curl_easy_perform(c);
+ if (hdrs) curl_slist_free_all(hdrs);
+ curl_easy_cleanup(c);
+
+ int ok1 = (fflush(f) == 0);
+ int ok2 = (fclose(f) == 0);
+
+ if (rc != CURLE_OK || !ok1 || !ok2) { remove(op); return 0; }
+ return 1;
+}
+
+/* ── HTTP server ─────────────────────────────────────────────────────────── */
+/* Delegate to el_runtime.c's http_serve / http_serve_v2 via the existing
+ * http_set_handler mechanism. */
+
+/* Forward declarations for el_runtime.c functions called from el_seed.c.
+ * A full #include "el_runtime.h" causes redefinition conflicts (el_seed.h
+ * already defines el_to_float, el_from_float, and several __ wrappers).
+ * Declare only the symbols actually used here. */
+
+/* HTTP server */
+el_val_t http_serve(el_val_t port, el_val_t handler);
+el_val_t http_serve_v2(el_val_t port, el_val_t handler);
+el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body);
+
+/* JSON */
+el_val_t json_get(el_val_t json, el_val_t key);
+el_val_t json_get_raw(el_val_t json_str, el_val_t key);
+el_val_t json_parse(el_val_t s);
+el_val_t json_stringify(el_val_t v);
+el_val_t json_array_len(el_val_t json_str);
+el_val_t json_array_get(el_val_t json_str, el_val_t index);
+el_val_t json_array_get_string(el_val_t json_str, el_val_t index);
+el_val_t json_get_string(el_val_t json_str, el_val_t key);
+el_val_t json_get_int(el_val_t json_str, el_val_t key);
+el_val_t json_get_float(el_val_t json_str, el_val_t key);
+el_val_t json_get_bool(el_val_t json_str, el_val_t key);
+el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value);
+
+/* URL / HTML */
+el_val_t url_encode(el_val_t s);
+el_val_t url_decode(el_val_t s);
+el_val_t el_html_sanitize(el_val_t input_html, el_val_t allowlist_json);
+
+/* State K/V */
+el_val_t state_set(el_val_t key, el_val_t value);
+el_val_t state_get(el_val_t key);
+el_val_t state_del(el_val_t key);
+el_val_t state_keys(void);
+el_val_t state_get_or(el_val_t key, el_val_t default_val);
+
+/* Engram graph */
+el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience);
+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_json);
+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 importance, el_val_t confidence,
+ el_val_t tier, el_val_t tags_json, el_val_t layer_id);
+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);
+el_val_t engram_remove_layer(el_val_t layer_id);
+el_val_t engram_list_layers(void);
+el_val_t engram_get_node(el_val_t id);
+void engram_strengthen(el_val_t node_id);
+void engram_forget(el_val_t node_id);
+el_val_t engram_node_count(void);
+el_val_t engram_search(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
+void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation);
+el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id);
+el_val_t engram_neighbors(el_val_t node_id);
+el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_edge_count(void);
+el_val_t engram_activate(el_val_t query, el_val_t depth);
+el_val_t engram_save(el_val_t path);
+el_val_t engram_load(el_val_t path);
+el_val_t engram_get_node_json(el_val_t id);
+el_val_t engram_search_json(el_val_t query, el_val_t limit);
+el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
+el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
+el_val_t engram_activate_json(el_val_t query, el_val_t depth);
+el_val_t engram_stats_json(void);
+el_val_t engram_list_layers_json(void);
+el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth);
+
+void __http_serve(el_val_t port, el_val_t handler_name) {
+ http_serve(port, handler_name);
+}
+
+void __http_serve_v2(el_val_t port, el_val_t handler_name) {
+ http_serve_v2(port, handler_name);
+}
+
+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);
+}
+
+/* ── HTTP SSE — Server-Sent Events streaming ─────────────────────────────── */
+/*
+ * Thread-local file descriptor stashed by http_worker_v2 before calling the
+ * El handler. El SSE builtins read this to get the raw socket fd.
+ *
+ * Lifecycle:
+ * http_worker_v2 sets _tl_http_conn_fd = fd (via el_seed_set_http_conn_fd)
+ * El handler calls __http_conn_fd() → receives that fd
+ * El handler calls __http_sse_open(fd) → sends SSE headers, keeps fd open
+ * El handler calls __http_sse_send(fd, data) → writes "data: ...\n\n"
+ * El handler calls __http_sse_close(fd) → closes the fd
+ * El handler returns "__sse__" sentinel → http_worker_v2 does NOT close fd
+ *
+ * The -1 value means no current connection (guard against misuse outside
+ * a handler context).
+ */
+static __thread int _tl_http_conn_fd = -1;
+
+/* Called by el_runtime.c's http_worker_v2 — not part of the El ABI. */
+void el_seed_set_http_conn_fd(int fd) {
+ _tl_http_conn_fd = fd;
+}
+
+/* __http_conn_fd() — returns the raw fd for the current HTTP connection.
+ * Valid only inside an http_serve_v2 handler before it returns. */
+el_val_t __http_conn_fd(void) {
+ return EL_INT(_tl_http_conn_fd);
+}
+
+/* __http_sse_open(fd) — sends SSE response headers on fd, keeping it open.
+ * Returns 1 on success, 0 on write failure. */
+el_val_t __http_sse_open(el_val_t conn_id) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ static const char sse_headers[] =
+ "HTTP/1.1 200 OK\r\n"
+ "Content-Type: text/event-stream\r\n"
+ "Cache-Control: no-cache\r\n"
+ "Connection: keep-alive\r\n"
+ "Access-Control-Allow-Origin: *\r\n"
+ "\r\n";
+ size_t n = sizeof(sse_headers) - 1; /* exclude NUL */
+ size_t sent = 0;
+ while (sent < n) {
+ ssize_t w = write(fd, sse_headers + sent, n - sent);
+ if (w <= 0) return 0;
+ sent += (size_t)w;
+ }
+ return 1;
+}
+
+/* __http_sse_send(fd, data) — writes one SSE event frame: "data: \n\n".
+ * data must not contain newlines. Returns 1 on success, 0 on client disconnect. */
+el_val_t __http_sse_send(el_val_t conn_id, el_val_t data) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ const char* s = EL_CSTR(data);
+ if (!s) s = "";
+ /* Build "data: \n\n" in a single buffer for one write call. */
+ size_t prefix_len = 6; /* "data: " */
+ size_t slen = strlen(s);
+ size_t total = prefix_len + slen + 2; /* + "\n\n" */
+ char* buf = malloc(total + 1);
+ if (!buf) return 0;
+ memcpy(buf, "data: ", 6);
+ memcpy(buf + 6, s, slen);
+ buf[6 + slen] = '\n';
+ buf[6 + slen + 1] = '\n';
+ buf[total] = '\0';
+ size_t sent = 0;
+ int ok = 1;
+ while (sent < total) {
+ ssize_t w = write(fd, buf + sent, total - sent);
+ if (w <= 0) { ok = 0; break; }
+ sent += (size_t)w;
+ }
+ free(buf);
+ return ok ? 1 : 0;
+}
+
+/* __http_sse_close(fd) — closes the SSE connection fd. */
+el_val_t __http_sse_close(el_val_t conn_id) {
+ int fd = (int)(int64_t)conn_id;
+ if (fd < 0) return 0;
+ close(fd);
+ return 1;
+}
+
+/* ── Threading ───────────────────────────────────────────────────────────── */
+/*
+ * Design:
+ * Static ElThread table (max EL_SEED_MAX_THREADS entries).
+ * __thread_create: pick a free slot, store fn_name + arg, launch pthread.
+ * Worker: dlsym(RTLD_DEFAULT, fn_name) → call as el_val_t fn(el_val_t).
+ * Store result string in slot.result.
+ * __thread_join: pthread_join, return stored result string.
+ *
+ * Thread handle is the slot index (0..EL_SEED_MAX_THREADS-1).
+ * Returns -1 on failure (no slots, dlsym failure, pthread_create failure).
+ *
+ * Each slot is guarded by its own mutex so join/create on different handles
+ * never contend.
+ */
+
+#define EL_SEED_MAX_THREADS 64
+
+typedef el_val_t (*ElFn1)(el_val_t);
+
+typedef struct {
+ int in_use;
+ char* fn_name;
+ char* arg;
+ char* result;
+ pthread_t tid;
+ int done; /* set to 1 by worker before exit */
+} ElThread;
+
+static ElThread _el_threads[EL_SEED_MAX_THREADS];
+static pthread_mutex_t _el_thread_mu = PTHREAD_MUTEX_INITIALIZER;
+
+typedef struct {
+ int slot;
+} ElThreadArg;
+
+static void* el_thread_worker(void* raw) {
+ ElThreadArg* ta = (ElThreadArg*)raw;
+ int slot = ta->slot;
+ free(ta);
+
+ ElThread* t = &_el_threads[slot];
+
+ /* Resolve the El function symbol in the running binary. */
+ void* sym = dlsym(RTLD_DEFAULT, t->fn_name);
+ if (!sym) {
+ pthread_mutex_lock(&_el_thread_mu);
+ t->result = seed_strdup_persist("");
+ t->done = 1;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return NULL;
+ }
+
+ ElFn1 fn = (ElFn1)sym;
+
+ /* Call the El function with the string argument. */
+ el_val_t arg_val = EL_STR(t->arg);
+ el_val_t ret = fn(arg_val);
+
+ /* Persist the result string. */
+ const char* rs = EL_CSTR(ret);
+ char* stored = seed_strdup_persist(rs ? rs : "");
+
+ pthread_mutex_lock(&_el_thread_mu);
+ t->result = stored;
+ t->done = 1;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ return NULL;
+}
+
+el_val_t __thread_create(el_val_t fn_name, el_val_t arg) {
+ const char* fname = EL_CSTR(fn_name);
+ const char* astr = EL_CSTR(arg);
+ if (!fname || !*fname) return (el_val_t)(int64_t)-1;
+ if (!astr) astr = "";
+
+ pthread_mutex_lock(&_el_thread_mu);
+ int slot = -1;
+ for (int i = 0; i < EL_SEED_MAX_THREADS; i++) {
+ if (!_el_threads[i].in_use) { slot = i; break; }
+ }
+ if (slot < 0) {
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+
+ ElThread* t = &_el_threads[slot];
+ t->in_use = 1;
+ t->done = 0;
+ t->fn_name = seed_strdup_persist(fname);
+ t->arg = seed_strdup_persist(astr);
+ t->result = NULL;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ ElThreadArg* ta = malloc(sizeof(ElThreadArg));
+ if (!ta) {
+ pthread_mutex_lock(&_el_thread_mu);
+ free(t->fn_name); free(t->arg);
+ t->in_use = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+ ta->slot = slot;
+
+ if (pthread_create(&t->tid, NULL, el_thread_worker, ta) != 0) {
+ free(ta);
+ pthread_mutex_lock(&_el_thread_mu);
+ free(t->fn_name); free(t->arg);
+ t->in_use = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+
+ return (el_val_t)(int64_t)slot;
+}
+
+el_val_t __thread_join(el_val_t tid) {
+ int64_t slot = (int64_t)tid;
+ if (slot < 0 || slot >= EL_SEED_MAX_THREADS) return seed_wrap_str(seed_strdup(""));
+
+ ElThread* t = &_el_threads[slot];
+
+ pthread_mutex_lock(&_el_thread_mu);
+ if (!t->in_use) {
+ pthread_mutex_unlock(&_el_thread_mu);
+ return seed_wrap_str(seed_strdup(""));
+ }
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ /* Wait for thread to finish. */
+ pthread_join(t->tid, NULL);
+
+ pthread_mutex_lock(&_el_thread_mu);
+ char* res = t->result ? t->result : "";
+ char* copy = seed_strdup(res); /* arena-tracked for caller lifetime */
+ free(t->fn_name);
+ free(t->arg);
+ if (t->result) { free(t->result); t->result = NULL; }
+ t->fn_name = NULL; t->arg = NULL;
+ t->in_use = 0;
+ t->done = 0;
+ pthread_mutex_unlock(&_el_thread_mu);
+
+ return seed_wrap_str(copy);
+}
+
+/* ── Mutex pool ──────────────────────────────────────────────────────────── */
+
+#define EL_SEED_MAX_MUTEXES 256
+
+static pthread_mutex_t _el_mutexes[EL_SEED_MAX_MUTEXES];
+static int _el_mutex_init[EL_SEED_MAX_MUTEXES];
+static int _el_mutex_used[EL_SEED_MAX_MUTEXES];
+static pthread_mutex_t _el_mutex_pool_mu = PTHREAD_MUTEX_INITIALIZER;
+
+el_val_t __mutex_new(void) {
+ pthread_mutex_lock(&_el_mutex_pool_mu);
+ int slot = -1;
+ for (int i = 0; i < EL_SEED_MAX_MUTEXES; i++) {
+ if (!_el_mutex_used[i]) { slot = i; break; }
+ }
+ if (slot < 0) {
+ pthread_mutex_unlock(&_el_mutex_pool_mu);
+ return (el_val_t)(int64_t)-1;
+ }
+ _el_mutex_used[slot] = 1;
+ if (!_el_mutex_init[slot]) {
+ pthread_mutex_init(&_el_mutexes[slot], NULL);
+ _el_mutex_init[slot] = 1;
+ }
+ pthread_mutex_unlock(&_el_mutex_pool_mu);
+ return (el_val_t)(int64_t)slot;
+}
+
+void __mutex_lock(el_val_t m) {
+ int64_t slot = (int64_t)m;
+ if (slot < 0 || slot >= EL_SEED_MAX_MUTEXES) return;
+ if (!_el_mutex_init[slot]) return;
+ pthread_mutex_lock(&_el_mutexes[slot]);
+}
+
+void __mutex_unlock(el_val_t m) {
+ int64_t slot = (int64_t)m;
+ if (slot < 0 || slot >= EL_SEED_MAX_MUTEXES) return;
+ if (!_el_mutex_init[slot]) return;
+ pthread_mutex_unlock(&_el_mutexes[slot]);
+}
+
+/* ── Subprocess ──────────────────────────────────────────────────────────── */
+
+el_val_t __exec(el_val_t cmd) {
+ const char* c = EL_CSTR(cmd);
+ if (!c) return seed_wrap_str(seed_strdup(""));
+ FILE* f = popen(c, "r");
+ if (!f) return seed_wrap_str(seed_strdup(""));
+ size_t cap = 4096, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) { pclose(f); return seed_wrap_str(seed_strdup("")); }
+ char tmp[4096];
+ while (fgets(tmp, sizeof(tmp), f)) {
+ size_t n = strlen(tmp);
+ while (len + n + 1 > cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); pclose(f); return seed_wrap_str(seed_strdup("")); }
+ buf = g;
+ }
+ memcpy(buf + len, tmp, n);
+ len += n;
+ buf[len] = '\0';
+ }
+ pclose(f);
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+void __exec_bg(el_val_t cmd) {
+ const char* c = EL_CSTR(cmd);
+ if (!c) return;
+ /* Fork-free: run in background via system() with & appended. */
+ size_t n = strlen(c);
+ char* s = malloc(n + 4);
+ if (!s) return;
+ memcpy(s, c, n);
+ memcpy(s + n, " &", 3);
+ system(s);
+ free(s);
+}
+
+/* ── Environment and process ─────────────────────────────────────────────── */
+
+el_val_t __env_get(el_val_t key) {
+ const char* k = EL_CSTR(key);
+ if (!k) return seed_wrap_str(seed_strdup(""));
+ const char* v = getenv(k);
+ return seed_wrap_str(seed_strdup(v ? v : ""));
+}
+
+void __exit_program(el_val_t code) {
+ exit((int)(int64_t)code);
+}
+
+/* ── args_json ────────────────────────────────────────────────────────────── */
+
+static int _seed_argc = 0;
+static char** _seed_argv = NULL;
+
+void el_seed_init_args(int argc, char** argv) {
+ _seed_argc = argc;
+ _seed_argv = argv;
+}
+
+el_val_t __args_json(void) {
+ /* Return ["arg1","arg2",...] as a JSON string. Skip argv[0] (program name). */
+ size_t cap = 256, len = 0;
+ char* buf = malloc(cap);
+ if (!buf) return seed_wrap_str(seed_strdup("[]"));
+ buf[len++] = '[';
+ int first = 1;
+ for (int i = 1; i < _seed_argc; i++) {
+ const char* a = _seed_argv[i];
+ /* Estimate: each arg needs at most strlen*6+4 bytes (worst case escape) */
+ size_t need = strlen(a) * 6 + 8;
+ while (len + need + 2 > cap) {
+ cap *= 2;
+ char* g = realloc(buf, cap);
+ if (!g) { free(buf); return seed_wrap_str(seed_strdup("[]")); }
+ buf = g;
+ }
+ if (!first) buf[len++] = ',';
+ first = 0;
+ buf[len++] = '"';
+ for (const char* p = a; *p; p++) {
+ unsigned char c = (unsigned char)*p;
+ if (c == '"') { buf[len++] = '\\'; buf[len++] = '"'; }
+ else if (c == '\\') { buf[len++] = '\\'; buf[len++] = '\\'; }
+ else if (c == '\n') { buf[len++] = '\\'; buf[len++] = 'n'; }
+ else if (c == '\r') { buf[len++] = '\\'; buf[len++] = 'r'; }
+ else if (c == '\t') { buf[len++] = '\\'; buf[len++] = 't'; }
+ else buf[len++] = (char)c;
+ }
+ buf[len++] = '"';
+ }
+ buf[len++] = ']';
+ buf[len] = '\0';
+ seed_arena_track(buf);
+ return seed_wrap_str(buf);
+}
+
+/* ── Time ────────────────────────────────────────────────────────────────── */
+
+el_val_t __time_now_ns(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);
+ return (el_val_t)((int64_t)tv.tv_sec * 1000000000LL + (int64_t)tv.tv_usec * 1000LL);
+}
+
+void __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);
+}
+
+/* ── UUID ────────────────────────────────────────────────────────────────── */
+
+static int _seed_uuid_seeded = 0;
+
+el_val_t __uuid_v4(void) {
+ if (!_seed_uuid_seeded) {
+ srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&_seed_uuid_seeded);
+ _seed_uuid_seeded = 1;
+ }
+ unsigned char b[16];
+ for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xff);
+ b[6] = (b[6] & 0x0f) | 0x40; /* version 4 */
+ b[8] = (b[8] & 0x3f) | 0x80; /* RFC 4122 variant */
+ 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 seed_wrap_str(seed_strdup(buf));
+}
+
+/* ── Math ────────────────────────────────────────────────────────────────── */
+
+el_val_t __sqrt_f(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
+el_val_t __log_f(el_val_t f) { return el_from_float(log10(el_to_float(f))); }
+el_val_t __ln_f(el_val_t f) { return el_from_float(log(el_to_float(f))); }
+el_val_t __sin_f(el_val_t f) { return el_from_float(sin(el_to_float(f))); }
+el_val_t __cos_f(el_val_t f) { return el_from_float(cos(el_to_float(f))); }
+el_val_t __pi_f(void) { return el_from_float(3.14159265358979323846); }
+
+/* ── JSON — thin wrappers around el_runtime.c implementations ────────────── */
+
+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_str, el_val_t key) { return json_get_raw(json_str, key); }
+el_val_t __json_parse(el_val_t s) { return json_parse(s); }
+el_val_t __json_stringify(el_val_t v) { return json_stringify(v); }
+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); }
+el_val_t __json_array_len(el_val_t json_str) { return json_array_len(json_str); }
+el_val_t __json_array_get(el_val_t json_str, el_val_t index) { return json_array_get(json_str, index); }
+el_val_t __json_array_get_string(el_val_t json_str, el_val_t index) { return json_array_get_string(json_str, index); }
+el_val_t __json_get_string(el_val_t json_str, el_val_t key) { return json_get_string(json_str, key); }
+el_val_t __json_get_int(el_val_t json_str, el_val_t key) { return json_get_int(json_str, key); }
+el_val_t __json_get_float(el_val_t json_str, el_val_t key) { return json_get_float(json_str, key); }
+el_val_t __json_get_bool(el_val_t json_str, el_val_t key) { return json_get_bool(json_str, key); }
+el_val_t __json_set(el_val_t json_str, el_val_t key, el_val_t value) { return json_set(json_str, key, value); }
+
+/* ── State K/V — thin wrappers ───────────────────────────────────────────── */
+
+el_val_t __state_set(el_val_t key, el_val_t value) { return state_set(key, value); }
+el_val_t __state_get(el_val_t key) { return state_get(key); }
+el_val_t __state_del(el_val_t key) { return state_del(key); }
+el_val_t __state_keys(void) { return state_keys(); }
+
+/* ── HTML/URL — thin wrappers ────────────────────────────────────────────── */
+
+el_val_t __html_sanitize(el_val_t input_html, el_val_t allowlist_json) {
+ return el_html_sanitize(input_html, allowlist_json);
+}
+
+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); }
+
+/* ── Engram — thin wrappers ──────────────────────────────────────────────── */
+
+el_val_t __engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
+ return engram_node(content, node_type, salience);
+}
+
+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) {
+ return engram_node_full(content, node_type, label, salience, importance, confidence, tier, tags);
+}
+
+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) {
+ return engram_node_layered(content, node_type, label, salience, certainty, confidence,
+ status, tags, layer_id);
+}
+
+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) {
+ return engram_add_layer(name, priority, suppressible, transparent, injectable);
+}
+
+el_val_t __engram_remove_layer(el_val_t layer_id) { return engram_remove_layer(layer_id); }
+el_val_t __engram_list_layers(void) { return engram_list_layers(); }
+el_val_t __engram_get_node(el_val_t id) { return engram_get_node(id); }
+void __engram_strengthen(el_val_t node_id) { engram_strengthen(node_id); }
+void __engram_forget(el_val_t node_id) { engram_forget(node_id); }
+el_val_t __engram_node_count(void) { return engram_node_count(); }
+
+el_val_t __engram_search(el_val_t query, el_val_t limit) { return engram_search(query, limit); }
+el_val_t __engram_scan_nodes(el_val_t limit, el_val_t offset) { return engram_scan_nodes(limit, offset); }
+
+void __engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
+ engram_connect(from_id, to_id, weight, relation);
+}
+
+el_val_t __engram_edge_between(el_val_t from_id, el_val_t to_id) {
+ return engram_edge_between(from_id, to_id);
+}
+
+el_val_t __engram_neighbors(el_val_t node_id) { return engram_neighbors(node_id); }
+
+el_val_t __engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
+ return engram_neighbors_filtered(node_id, max_depth, direction);
+}
+
+el_val_t __engram_edge_count(void) { return engram_edge_count(); }
+
+el_val_t __engram_activate(el_val_t query, el_val_t depth) { return engram_activate(query, depth); }
+el_val_t __engram_save(el_val_t path) { return engram_save(path); }
+el_val_t __engram_load(el_val_t path) { return engram_load(path); }
+
+el_val_t __engram_get_node_json(el_val_t id) { return engram_get_node_json(id); }
+
+el_val_t __engram_search_json(el_val_t query, el_val_t limit) {
+ return engram_search_json(query, limit);
+}
+
+el_val_t __engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
+ return engram_scan_nodes_json(limit, offset);
+}
+
+el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset) {
+ return engram_scan_nodes_by_type_json(node_type, limit, offset);
+}
+
+el_val_t __engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
+ return engram_neighbors_json(node_id, max_depth, direction);
+}
+
+el_val_t __engram_activate_json(el_val_t query, el_val_t depth) {
+ return engram_activate_json(query, depth);
+}
+
+el_val_t __engram_stats_json(void) { return engram_stats_json(); }
+el_val_t __engram_list_layers_json(void) { return engram_list_layers_json(); }
+
+el_val_t __engram_compile_layered_json(el_val_t intent, el_val_t depth) {
+ return engram_compile_layered_json(intent, depth);
+}
+
+/* ── Cryptographic hashing ────────────────────────────────────────────────── */
+/*
+ * SHA-256 — self-contained implementation (no OpenSSL dependency).
+ * Based on Brad Conte's public-domain reference implementation.
+ */
+
+typedef struct {
+ uint8_t data[64];
+ uint32_t datalen;
+ uint64_t bitlen;
+ uint32_t state[8];
+} _seed_sha256_ctx;
+
+static const uint32_t _seed_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 _SEED_ROTR32(x,n) (((x)>>(n))|((x)<<(32-(n))))
+#define _SEED_CH(x,y,z) (((x)&(y))^(~(x)&(z)))
+#define _SEED_MAJ(x,y,z) (((x)&(y))^((x)&(z))^((y)&(z)))
+#define _SEED_EP0(x) (_SEED_ROTR32(x,2)^_SEED_ROTR32(x,13)^_SEED_ROTR32(x,22))
+#define _SEED_EP1(x) (_SEED_ROTR32(x,6)^_SEED_ROTR32(x,11)^_SEED_ROTR32(x,25))
+#define _SEED_SIG0(x) (_SEED_ROTR32(x,7)^_SEED_ROTR32(x,18)^((x)>>3))
+#define _SEED_SIG1(x) (_SEED_ROTR32(x,17)^_SEED_ROTR32(x,19)^((x)>>10))
+
+static void _seed_sha256_transform(_seed_sha256_ctx* ctx, const uint8_t* data) {
+ uint32_t a,b,c,d,e,f,g,h,t1,t2,m[64];
+ for (int i=0,j=0; i<16; i++,j+=4)
+ m[i]=(uint32_t)(data[j]<<24)|(data[j+1]<<16)|(data[j+2]<<8)|data[j+3];
+ for (int i=16; i<64; i++)
+ m[i]=_SEED_SIG1(m[i-2])+m[i-7]+_SEED_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 (int i=0; i<64; i++) {
+ t1=h+_SEED_EP1(e)+_SEED_CH(e,f,g)+_seed_sha256_k[i]+m[i];
+ t2=_SEED_EP0(a)+_SEED_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 _seed_sha256_init(_seed_sha256_ctx* 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 _seed_sha256_update(_seed_sha256_ctx* ctx, const uint8_t* data, size_t len) {
+ for (size_t i=0; idata[ctx->datalen++] = data[i];
+ if (ctx->datalen==64) { _seed_sha256_transform(ctx,ctx->data); ctx->bitlen+=512; ctx->datalen=0; }
+ }
+}
+
+static void _seed_sha256_final(_seed_sha256_ctx* ctx, uint8_t hash[32]) {
+ uint32_t i=ctx->datalen;
+ ctx->data[i++]=0x80;
+ if (ctx->datalen<56) { while(i<56) ctx->data[i++]=0; }
+ else { while(i<64) ctx->data[i++]=0; _seed_sha256_transform(ctx,ctx->data); memset(ctx->data,0,56); }
+ ctx->bitlen+=ctx->datalen*8;
+ ctx->data[63]=(uint8_t)(ctx->bitlen); ctx->data[62]=(uint8_t)(ctx->bitlen>>8);
+ ctx->data[61]=(uint8_t)(ctx->bitlen>>16); ctx->data[60]=(uint8_t)(ctx->bitlen>>24);
+ ctx->data[59]=(uint8_t)(ctx->bitlen>>32); ctx->data[58]=(uint8_t)(ctx->bitlen>>40);
+ ctx->data[57]=(uint8_t)(ctx->bitlen>>48); ctx->data[56]=(uint8_t)(ctx->bitlen>>56);
+ _seed_sha256_transform(ctx,ctx->data);
+ for (i=0; i<4; i++) {
+ hash[i] =(uint8_t)(ctx->state[0]>>(24-i*8));
+ hash[i+4] =(uint8_t)(ctx->state[1]>>(24-i*8));
+ hash[i+8] =(uint8_t)(ctx->state[2]>>(24-i*8));
+ hash[i+12] =(uint8_t)(ctx->state[3]>>(24-i*8));
+ hash[i+16] =(uint8_t)(ctx->state[4]>>(24-i*8));
+ hash[i+20] =(uint8_t)(ctx->state[5]>>(24-i*8));
+ hash[i+24] =(uint8_t)(ctx->state[6]>>(24-i*8));
+ hash[i+28] =(uint8_t)(ctx->state[7]>>(24-i*8));
+ }
+}
+
+/* ── Manifest reader (shared: macOS + Linux) ─────────────────────────────── */
+/*
+ * Parse the app{} block from a manifest.el file.
+ * Expected format (subset — only app{} block is parsed):
+ *
+ * app {
+ * window_title "My App"
+ * window_width 1200
+ * window_height 800
+ * min_width 600
+ * min_height 400
+ * }
+ *
+ * Returns JSON: {"title":"...","width":N,"height":N,"min_width":N,"min_height":N}
+ * Returns "{}" on parse failure.
+ *
+ * Compiled unconditionally so both EL_TARGET_MACOS and EL_TARGET_LINUX can use
+ * it — the __manifest_read builtin is identical on all native targets.
+ */
+
+static void seed_manifest_skip_ws(const char** p) {
+ while (**p && (unsigned char)**p <= ' ') (*p)++;
+}
+
+/* Read a quoted string literal. Caller must free() the result. */
+static char* seed_manifest_read_string(const char** p) {
+ seed_manifest_skip_ws(p);
+ if (**p != '"') return NULL;
+ (*p)++;
+ const char* start = *p;
+ while (**p && **p != '"') {
+ if (**p == '\\') (*p)++; /* skip escaped char */
+ (*p)++;
+ }
+ size_t len = (size_t)(*p - start);
+ if (**p == '"') (*p)++;
+ char* out = malloc(len + 1);
+ if (!out) return NULL;
+ memcpy(out, start, len);
+ out[len] = '\0';
+ return out;
+}
+
+/* Read an unquoted integer literal. */
+static int64_t seed_manifest_read_int(const char** p) {
+ seed_manifest_skip_ws(p);
+ int64_t result = 0;
+ int neg = 0;
+ if (**p == '-') { neg = 1; (*p)++; }
+ while (**p >= '0' && **p <= '9') {
+ result = result * 10 + (**p - '0');
+ (*p)++;
+ }
+ return neg ? -result : result;
+}
+
+/* Find "app {" block start. Returns pointer past the '{'. */
+static const char* seed_manifest_find_app_block(const char* src) {
+ while (*src) {
+ /* Skip line comments (// ...) */
+ if (src[0] == '/' && src[1] == '/') {
+ while (*src && *src != '\n') src++;
+ continue;
+ }
+ if (strncmp(src, "app", 3) == 0 &&
+ (src[3] == ' ' || src[3] == '\t' || src[3] == '\n' || src[3] == '{')) {
+ src += 3;
+ while (*src && *src != '{') src++;
+ if (*src == '{') return src + 1;
+ return NULL;
+ }
+ src++;
+ }
+ return NULL;
+}
+
+el_val_t __manifest_read(el_val_t path) {
+ const char* p = EL_CSTR(path);
+ if (!p || !*p) return seed_wrap_str(seed_strdup("{}"));
+
+ /* Read the file. */
+ FILE* f = fopen(p, "r");
+ if (!f) return seed_wrap_str(seed_strdup("{}"));
+ fseek(f, 0, SEEK_END);
+ long sz = ftell(f);
+ rewind(f);
+ if (sz <= 0) { fclose(f); return seed_wrap_str(seed_strdup("{}")); }
+ char* src = malloc((size_t)sz + 1);
+ if (!src) { fclose(f); return seed_wrap_str(seed_strdup("{}")); }
+ size_t got = fread(src, 1, (size_t)sz, f);
+ src[got] = '\0';
+ fclose(f);
+
+ const char* app_start = seed_manifest_find_app_block(src);
+ if (!app_start) { free(src); return seed_wrap_str(seed_strdup("{}")); }
+
+ /* Parse key-value pairs inside the app { } block. */
+ char* title = NULL;
+ int64_t width = 1200;
+ int64_t height = 800;
+ int64_t min_w = 600;
+ int64_t min_h = 400;
+
+ const char* cur = app_start;
+ while (*cur && *cur != '}') {
+ /* Skip whitespace and comments. */
+ while (*cur && (unsigned char)*cur <= ' ') cur++;
+ if (*cur == '}') break;
+ if (cur[0] == '/' && cur[1] == '/') {
+ while (*cur && *cur != '\n') cur++;
+ continue;
+ }
+
+ /* Read key token. */
+ const char* key_start = cur;
+ while (*cur && (unsigned char)*cur > ' ' && *cur != '{' && *cur != '}') cur++;
+ size_t key_len = (size_t)(cur - key_start);
+ if (key_len == 0) { cur++; continue; }
+
+ /* Skip whitespace between key and value. */
+ while (*cur == ' ' || *cur == '\t') cur++;
+
+ if (strncmp(key_start, "window_title", key_len) == 0 && key_len == 12) {
+ free(title);
+ title = seed_manifest_read_string(&cur);
+ } else if (strncmp(key_start, "window_width", key_len) == 0 && key_len == 12) {
+ width = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "window_height", key_len) == 0 && key_len == 13) {
+ height = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "min_width", key_len) == 0 && key_len == 9) {
+ min_w = seed_manifest_read_int(&cur);
+ } else if (strncmp(key_start, "min_height", key_len) == 0 && key_len == 10) {
+ min_h = seed_manifest_read_int(&cur);
+ } else {
+ /* Unknown key — skip to end of line. */
+ while (*cur && *cur != '\n' && *cur != '}') cur++;
+ }
+ }
+
+ free(src);
+
+ /* Build JSON result. */
+ const char* t = title ? title : "App";
+ size_t buf_sz = strlen(t) * 6 + 128;
+ char* buf = seed_strbuf(buf_sz);
+ snprintf(buf, buf_sz,
+ "{\"title\":\"%s\",\"width\":%lld,\"height\":%lld,\"min_width\":%lld,\"min_height\":%lld}",
+ t, (long long)width, (long long)height, (long long)min_w, (long long)min_h);
+ free(title);
+ return seed_wrap_str(buf);
+}
+
+/* ── Native widget system — macOS AppKit ─────────────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_MACOS is defined.
+ * They call through to el_appkit.m (Objective-C) which implements the actual
+ * AppKit widget creation and management.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_appkit_* function, return the result.
+ *
+ * Compile requirements:
+ * - Compile el_appkit.m alongside this file (clang -ObjC -framework Cocoa)
+ * - Pass -DEL_TARGET_MACOS to enable this section
+ */
+
+#ifdef EL_TARGET_MACOS
+
+/* Forward declarations — implemented in el_appkit.m */
+extern void el_appkit_init(void);
+extern void el_appkit_run_loop(void);
+extern int64_t el_appkit_window_create(const char* title, int w, int h, int mw, int mh);
+extern void el_appkit_window_show(int64_t handle);
+extern void el_appkit_window_set_title(int64_t handle, const char* title);
+extern int64_t el_appkit_vstack_create(int spacing);
+extern int64_t el_appkit_hstack_create(int spacing);
+extern int64_t el_appkit_zstack_create(void);
+extern int64_t el_appkit_scroll_create(void);
+extern int64_t el_appkit_label_create(const char* text);
+extern int64_t el_appkit_button_create(const char* label);
+extern int64_t el_appkit_text_field_create(const char* placeholder);
+extern int64_t el_appkit_text_area_create(const char* placeholder);
+extern int64_t el_appkit_image_create(const char* path_or_name);
+extern void el_appkit_widget_set_text(int64_t handle, const char* text);
+extern const char* el_appkit_widget_get_text(int64_t handle);
+extern void el_appkit_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_appkit_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_appkit_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_appkit_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_appkit_widget_set_width(int64_t h, int width);
+extern void el_appkit_widget_set_height(int64_t h, int height);
+extern void el_appkit_widget_set_flex(int64_t h, int flex);
+extern void el_appkit_widget_set_corner_radius(int64_t h, int radius);
+extern void el_appkit_widget_set_disabled(int64_t h, int disabled);
+extern void el_appkit_widget_set_hidden(int64_t h, int hidden);
+extern void el_appkit_widget_add_child(int64_t parent, int64_t child);
+extern void el_appkit_widget_remove_child(int64_t parent, int64_t child);
+extern void el_appkit_widget_destroy(int64_t handle);
+extern void el_appkit_widget_on_click(int64_t h, const char* fn_name);
+extern void el_appkit_widget_on_change(int64_t h, const char* fn_name);
+extern void el_appkit_widget_on_submit(int64_t h, const char* fn_name);
+
+/* ── Native widget builtins ──────────────────────────────────────────────── */
+
+void __native_init(void) { el_appkit_init(); }
+void __native_run_loop(void) { el_appkit_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_appkit_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_appkit_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_appkit_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_appkit_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_appkit_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_appkit_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_appkit_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_appkit_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_appkit_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_appkit_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_appkit_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_appkit_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_appkit_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_appkit_widget_get_text((int64_t)h);
+ if (!s) return seed_wrap_str(seed_strdup(""));
+ /* el_appkit returns strdup'd string — wrap it (arena won't track it, but
+ * caller must treat return as short-lived or copy it). */
+ return EL_STR(s);
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_appkit_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_appkit_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_appkit_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_appkit_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_appkit_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_appkit_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_appkit_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_appkit_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_appkit_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_appkit_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_appkit_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_appkit_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_appkit_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_appkit_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_MACOS */
+
+/* ── Native widget system — Linux GTK4 ──────────────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_LINUX is defined.
+ * They call through to el_gtk4.c which implements the GTK4 widget layer.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_gtk4_* function, return the result.
+ *
+ * Compile requirements:
+ * - Compile el_gtk4.c alongside this file
+ * gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) el_gtk4.c -c ...
+ * - Pass -DEL_TARGET_LINUX to enable this section
+ * - Link with $(pkg-config --libs gtk4) -ldl
+ *
+ * __manifest_read is shared with the macOS path (implemented above in the
+ * seed_manifest_* helpers) and compiled for both targets unconditionally.
+ */
+
+#ifdef EL_TARGET_LINUX
+
+/* Forward declarations — implemented in el_gtk4.c */
+extern void el_gtk4_init(void);
+extern void el_gtk4_run_loop(void);
+extern int64_t el_gtk4_window_create(const char* title, int w, int h,
+ int mw, int mh);
+extern void el_gtk4_window_show(int64_t handle);
+extern void el_gtk4_window_set_title(int64_t handle, const char* title);
+extern int64_t el_gtk4_vstack_create(int spacing);
+extern int64_t el_gtk4_hstack_create(int spacing);
+extern int64_t el_gtk4_zstack_create(void);
+extern int64_t el_gtk4_scroll_create(void);
+extern int64_t el_gtk4_label_create(const char* text);
+extern int64_t el_gtk4_button_create(const char* label);
+extern int64_t el_gtk4_text_field_create(const char* placeholder);
+extern int64_t el_gtk4_text_area_create(const char* placeholder);
+extern int64_t el_gtk4_image_create(const char* path_or_name);
+extern void el_gtk4_widget_set_text(int64_t handle, const char* text);
+extern const char* el_gtk4_widget_get_text(int64_t handle);
+extern void el_gtk4_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_gtk4_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_gtk4_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_gtk4_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_gtk4_widget_set_width(int64_t h, int width);
+extern void el_gtk4_widget_set_height(int64_t h, int height);
+extern void el_gtk4_widget_set_flex(int64_t h, int flex);
+extern void el_gtk4_widget_set_corner_radius(int64_t h, int radius);
+extern void el_gtk4_widget_set_disabled(int64_t h, int disabled);
+extern void el_gtk4_widget_set_hidden(int64_t h, int hidden);
+extern void el_gtk4_widget_add_child(int64_t parent, int64_t child);
+extern void el_gtk4_widget_remove_child(int64_t parent, int64_t child);
+extern void el_gtk4_widget_destroy(int64_t handle);
+extern void el_gtk4_widget_on_click(int64_t h, const char* fn_name);
+extern void el_gtk4_widget_on_change(int64_t h, const char* fn_name);
+extern void el_gtk4_widget_on_submit(int64_t h, const char* fn_name);
+
+void __native_init(void) { el_gtk4_init(); }
+void __native_run_loop(void) { el_gtk4_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_gtk4_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_gtk4_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_gtk4_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_gtk4_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_gtk4_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_gtk4_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_gtk4_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_gtk4_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_gtk4_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_gtk4_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_gtk4_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_gtk4_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_gtk4_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_gtk4_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s); /* el_gtk4 returns strdup'd string */
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_gtk4_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_gtk4_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_gtk4_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_gtk4_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_gtk4_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_gtk4_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_gtk4_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_gtk4_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_gtk4_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_gtk4_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_gtk4_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_gtk4_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_gtk4_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_gtk4_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_LINUX */
+
+/* ── Native widget system — SDL2 (embedded / Pi) ─────────────────────────── */
+/*
+ * These builtins are compiled in only when EL_TARGET_SDL2 is defined.
+ * They call through to el_sdl2.c which implements a retained-mode pixel
+ * widget layer on top of SDL2 + SDL_ttf + SDL_image.
+ *
+ * Each builtin is a thin wrapper: marshal el_val_t ↔ C types, call the
+ * el_sdl2_* function, return the result.
+ *
+ * Compile requirements:
+ * gcc -DEL_TARGET_SDL2 $(sdl2-config --cflags) el_sdl2.c -c ...
+ * Pass -DEL_TARGET_SDL2 to enable this section.
+ * Link:
+ * $(sdl2-config --libs) -lSDL2_ttf -lSDL2_image -ldl
+ *
+ * __manifest_read is shared with the macOS/Linux paths (implemented above).
+ */
+
+#ifdef EL_TARGET_SDL2
+
+/* Forward declarations — implemented in el_sdl2.c */
+extern void el_sdl2_init(void);
+extern void el_sdl2_run_loop(void);
+extern int64_t el_sdl2_window_create(const char* title, int w, int h,
+ int mw, int mh);
+extern void el_sdl2_window_show(int64_t handle);
+extern void el_sdl2_window_set_title(int64_t handle, const char* title);
+extern int64_t el_sdl2_vstack_create(int spacing);
+extern int64_t el_sdl2_hstack_create(int spacing);
+extern int64_t el_sdl2_zstack_create(void);
+extern int64_t el_sdl2_scroll_create(void);
+extern int64_t el_sdl2_label_create(const char* text);
+extern int64_t el_sdl2_button_create(const char* label);
+extern int64_t el_sdl2_text_field_create(const char* placeholder);
+extern int64_t el_sdl2_text_area_create(const char* placeholder);
+extern int64_t el_sdl2_image_create(const char* path_or_name);
+extern void el_sdl2_widget_set_text(int64_t handle, const char* text);
+extern const char* el_sdl2_widget_get_text(int64_t handle);
+extern void el_sdl2_widget_set_color(int64_t h, float r, float g, float b, float a);
+extern void el_sdl2_widget_set_bg_color(int64_t h, float r, float g, float b, float a);
+extern void el_sdl2_widget_set_font(int64_t h, const char* family, int size, int bold);
+extern void el_sdl2_widget_set_padding(int64_t h, int top, int right, int bottom, int left);
+extern void el_sdl2_widget_set_width(int64_t h, int width);
+extern void el_sdl2_widget_set_height(int64_t h, int height);
+extern void el_sdl2_widget_set_flex(int64_t h, int flex);
+extern void el_sdl2_widget_set_corner_radius(int64_t h, int radius);
+extern void el_sdl2_widget_set_disabled(int64_t h, int disabled);
+extern void el_sdl2_widget_set_hidden(int64_t h, int hidden);
+extern void el_sdl2_widget_add_child(int64_t parent, int64_t child);
+extern void el_sdl2_widget_remove_child(int64_t parent, int64_t child);
+extern void el_sdl2_widget_destroy(int64_t handle);
+extern void el_sdl2_widget_on_click(int64_t h, const char* fn_name);
+extern void el_sdl2_widget_on_change(int64_t h, const char* fn_name);
+extern void el_sdl2_widget_on_submit(int64_t h, const char* fn_name);
+
+void __native_init(void) { el_sdl2_init(); }
+void __native_run_loop(void) { el_sdl2_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_sdl2_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_sdl2_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_sdl2_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_sdl2_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_sdl2_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_sdl2_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_sdl2_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_sdl2_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_sdl2_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_sdl2_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_sdl2_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_sdl2_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_sdl2_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char* s = el_sdl2_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s);
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_sdl2_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_sdl2_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_sdl2_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_sdl2_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_sdl2_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_sdl2_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_sdl2_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_sdl2_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_sdl2_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_sdl2_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_sdl2_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_sdl2_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_sdl2_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_sdl2_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_SDL2 */
+
+/* ── EL_TARGET_LVGL — LVGL v9 embedded backend ───────────────────────────────
+ *
+ * These builtins are compiled in only when EL_TARGET_LVGL is defined.
+ * Each wrapper translates el_val_t arguments (opaque int64_t) into the typed
+ * parameters that el_lvgl_* expects, then calls through.
+ *
+ * Build:
+ * gcc -DEL_TARGET_LVGL -I./lvgl el_lvgl.c el_seed.c -c ...
+ * # Link with lvgl.a. For bare-metal add -DEL_LVGL_NO_DLSYM.
+ *
+ * __manifest_read is shared — defined unconditionally above.
+ */
+
+#ifdef EL_TARGET_LVGL
+
+extern void el_lvgl_init(void);
+extern void el_lvgl_run_loop(void);
+extern int64_t el_lvgl_window_create(const char *title, int w, int h,
+ int mw, int mh);
+extern void el_lvgl_window_show(int64_t handle);
+extern void el_lvgl_window_set_title(int64_t handle, const char *title);
+extern int64_t el_lvgl_vstack_create(int spacing);
+extern int64_t el_lvgl_hstack_create(int spacing);
+extern int64_t el_lvgl_zstack_create(void);
+extern int64_t el_lvgl_scroll_create(void);
+extern int64_t el_lvgl_label_create(const char *text);
+extern int64_t el_lvgl_button_create(const char *label);
+extern int64_t el_lvgl_text_field_create(const char *placeholder);
+extern int64_t el_lvgl_text_area_create(const char *placeholder);
+extern int64_t el_lvgl_image_create(const char *path_or_name);
+extern void el_lvgl_widget_set_text(int64_t handle, const char *text);
+extern const char *el_lvgl_widget_get_text(int64_t handle);
+extern void el_lvgl_widget_set_color(int64_t h, float r, float g,
+ float b, float a);
+extern void el_lvgl_widget_set_bg_color(int64_t h, float r, float g,
+ float b, float a);
+extern void el_lvgl_widget_set_font(int64_t h, const char *family,
+ int size, int bold);
+extern void el_lvgl_widget_set_padding(int64_t h, int top, int right,
+ int bottom, int left);
+extern void el_lvgl_widget_set_width(int64_t h, int width);
+extern void el_lvgl_widget_set_height(int64_t h, int height);
+extern void el_lvgl_widget_set_flex(int64_t h, int flex);
+extern void el_lvgl_widget_set_corner_radius(int64_t h, int radius);
+extern void el_lvgl_widget_set_disabled(int64_t h, int disabled);
+extern void el_lvgl_widget_set_hidden(int64_t h, int hidden);
+extern void el_lvgl_widget_add_child(int64_t parent, int64_t child);
+extern void el_lvgl_widget_remove_child(int64_t parent, int64_t child);
+extern void el_lvgl_widget_destroy(int64_t handle);
+extern void el_lvgl_widget_on_click(int64_t h, const char *fn_name);
+extern void el_lvgl_widget_on_change(int64_t h, const char *fn_name);
+extern void el_lvgl_widget_on_submit(int64_t h, const char *fn_name);
+
+void __native_init(void) { el_lvgl_init(); }
+void __native_run_loop(void) { el_lvgl_run_loop(); }
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_lvgl_window_create(
+ EL_CSTR(title),
+ (int)(int64_t)width, (int)(int64_t)height,
+ (int)(int64_t)min_width, (int)(int64_t)min_height);
+}
+
+void __window_show(el_val_t h) { el_lvgl_window_show((int64_t)h); }
+void __window_set_title(el_val_t h, el_val_t t) { el_lvgl_window_set_title((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __vstack_create(el_val_t spacing) { return (el_val_t)el_lvgl_vstack_create((int)(int64_t)spacing); }
+el_val_t __hstack_create(el_val_t spacing) { return (el_val_t)el_lvgl_hstack_create((int)(int64_t)spacing); }
+el_val_t __zstack_create(void) { return (el_val_t)el_lvgl_zstack_create(); }
+el_val_t __scroll_create(void) { return (el_val_t)el_lvgl_scroll_create(); }
+
+el_val_t __label_create(el_val_t text) { return (el_val_t)el_lvgl_label_create(EL_CSTR(text)); }
+el_val_t __button_create(el_val_t label) { return (el_val_t)el_lvgl_button_create(EL_CSTR(label)); }
+el_val_t __text_field_create(el_val_t placeholder) { return (el_val_t)el_lvgl_text_field_create(EL_CSTR(placeholder)); }
+el_val_t __text_area_create(el_val_t placeholder) { return (el_val_t)el_lvgl_text_area_create(EL_CSTR(placeholder)); }
+el_val_t __image_create(el_val_t path_or_name) { return (el_val_t)el_lvgl_image_create(EL_CSTR(path_or_name)); }
+
+void __widget_set_text(el_val_t h, el_val_t t) { el_lvgl_widget_set_text((int64_t)h, EL_CSTR(t)); }
+
+el_val_t __widget_get_text(el_val_t h) {
+ const char *s = el_lvgl_widget_get_text((int64_t)h);
+ if (!s) return EL_STR("");
+ return EL_STR(s); /* pointer into LVGL internal storage — valid until next LVGL op */
+}
+
+void __widget_set_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_lvgl_widget_set_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_bg_color(el_val_t h, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ el_lvgl_widget_set_bg_color((int64_t)h,
+ (float)el_to_float(r), (float)el_to_float(g),
+ (float)el_to_float(b), (float)el_to_float(a));
+}
+
+void __widget_set_font(el_val_t h, el_val_t family, el_val_t size, el_val_t bold) {
+ el_lvgl_widget_set_font((int64_t)h, EL_CSTR(family),
+ (int)(int64_t)size, (int)(int64_t)bold);
+}
+
+void __widget_set_padding(el_val_t h, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_lvgl_widget_set_padding((int64_t)h,
+ (int)(int64_t)top, (int)(int64_t)right,
+ (int)(int64_t)bottom, (int)(int64_t)left);
+}
+
+void __widget_set_width(el_val_t h, el_val_t w) { el_lvgl_widget_set_width((int64_t)h, (int)(int64_t)w); }
+void __widget_set_height(el_val_t h, el_val_t ht) { el_lvgl_widget_set_height((int64_t)h, (int)(int64_t)ht); }
+void __widget_set_flex(el_val_t h, el_val_t f) { el_lvgl_widget_set_flex((int64_t)h, (int)(int64_t)f); }
+void __widget_set_corner_radius(el_val_t h, el_val_t r) { el_lvgl_widget_set_corner_radius((int64_t)h, (int)(int64_t)r); }
+void __widget_set_disabled(el_val_t h, el_val_t d) { el_lvgl_widget_set_disabled((int64_t)h, (int)(int64_t)d); }
+void __widget_set_hidden(el_val_t h, el_val_t hid) { el_lvgl_widget_set_hidden((int64_t)h, (int)(int64_t)hid); }
+void __widget_add_child(el_val_t p, el_val_t c) { el_lvgl_widget_add_child((int64_t)p, (int64_t)c); }
+void __widget_remove_child(el_val_t p, el_val_t c) { el_lvgl_widget_remove_child((int64_t)p, (int64_t)c); }
+void __widget_destroy(el_val_t h) { el_lvgl_widget_destroy((int64_t)h); }
+
+void __widget_on_click(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_click((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_change(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_change((int64_t)h, EL_CSTR(fn_name)); }
+void __widget_on_submit(el_val_t h, el_val_t fn_name) { el_lvgl_widget_on_submit((int64_t)h, EL_CSTR(fn_name)); }
+
+#endif /* EL_TARGET_LVGL */
+
+/* ── Cryptographic hashing ────────────────────────────────────────────────── */
+
+el_val_t __sha256_hex(el_val_t sv) {
+ const char* s = EL_CSTR(sv);
+ if (!s) s = "";
+ _seed_sha256_ctx ctx;
+ _seed_sha256_init(&ctx);
+ _seed_sha256_update(&ctx, (const uint8_t*)s, strlen(s));
+ uint8_t digest[32];
+ _seed_sha256_final(&ctx, digest);
+ static const char hex[] = "0123456789abcdef";
+ char* out = malloc(65);
+ if (!out) return EL_STR("");
+ for (int i=0; i<32; i++) {
+ out[i*2] = hex[(digest[i]>>4)&0xf];
+ out[i*2+1] = hex[digest[i]&0xf];
+ }
+ out[64] = '\0';
+ return EL_STR(out);
+}
diff --git a/ui/examples/native-hello-ios/NativeHello/el_uikit.m b/ui/examples/native-hello-ios/NativeHello/el_uikit.m
new file mode 100644
index 0000000..01a97a1
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/el_uikit.m
@@ -0,0 +1,1118 @@
+/*
+ * el_uikit.m — UIKit backend for the el native widget system.
+ *
+ * This file implements the iOS UIKit widget layer that el_seed.c calls
+ * through to when EL_TARGET_IOS is defined.
+ *
+ * Architecture:
+ * el program (el code)
+ * → __widget_* C builtins in el_seed.c
+ * → el_uikit_* C-callable functions declared here
+ * → UIView/UIWindow/UIStackView/UIButton/etc. via ObjC
+ *
+ * Widget handles: every widget (window, view, control) is assigned an int64_t
+ * slot index into _el_widgets[]. The el program holds these as opaque Int values.
+ * Slot 0 is never valid (reserved as null handle = -1 convention).
+ *
+ * Threading: All UIKit calls MUST run on the main thread. el_uikit_sync_main
+ * bounces work to the main thread synchronously if called from a background thread.
+ *
+ * Callback mechanism: when a widget fires an event (button tap, text change),
+ * the bridge looks up the registered callback function name, then calls
+ * dlsym(RTLD_DEFAULT, fn_name)(widget_handle, event_data_string)
+ * This resolves the El function symbol at runtime, matching the __thread_create
+ * pattern already established in el_seed.c.
+ *
+ * iOS lifecycle: UIApplicationMain never returns. The el program's "main" body
+ * (window creation, layout, show) must execute inside applicationDidFinishLaunchingWithOptions.
+ * The caller sets el_main_entry_fn before calling __native_run_loop so the
+ * delegate can invoke it at the right time.
+ *
+ * Compile (MRC — no ARC; manual retain/release needed for struct-embedded id):
+ * clang -std=gnu11 -ObjC -fno-objc-arc -DEL_TARGET_IOS -framework UIKit \
+ * -c el_uikit.m -o el_uikit.o
+ * Then link el_uikit.o alongside el_seed.c.
+ *
+ * Do NOT use -fobjc-arc: the widget table stores id in a plain C struct. ARC
+ * forbids explicit retain/release on struct-embedded object pointers and cannot
+ * insert automatic retain/release through C struct boundaries. MRC is the
+ * correct choice for this kind of C/ObjC bridge.
+ *
+ * Link flags required:
+ * -framework UIKit
+ */
+
+#ifdef EL_TARGET_IOS
+
+#import
+#import
+#include
+#include
+#include
+#include
+#include
+#include "el_runtime.h"
+
+/* ── Widget table ─────────────────────────────────────────────────────────── */
+
+#define EL_UIKIT_MAX_WIDGETS 4096
+
+typedef enum {
+ EL_WIDGET_FREE = 0,
+ EL_WIDGET_WINDOW = 1,
+ EL_WIDGET_VSTACK = 2,
+ EL_WIDGET_HSTACK = 3,
+ EL_WIDGET_ZSTACK = 4,
+ EL_WIDGET_SCROLL = 5,
+ EL_WIDGET_LABEL = 6,
+ EL_WIDGET_BUTTON = 7,
+ EL_WIDGET_TEXTFIELD = 8,
+ EL_WIDGET_TEXTAREA = 9,
+ EL_WIDGET_IMAGE = 10,
+ EL_WIDGET_DIVIDER = 11,
+ EL_WIDGET_SPACER = 12,
+} ElWidgetKind;
+
+typedef struct {
+ ElWidgetKind kind;
+ id obj; /* UIWindow* or UIView* subclass, retained */
+ char* cb_click; /* El function name for click/submit events */
+ char* cb_change; /* El function name for value-change events */
+} ElWidget;
+
+static ElWidget _el_widgets[EL_UIKIT_MAX_WIDGETS];
+static int _el_widget_count = 0;
+
+/* Side table for ObjC delegate objects keyed by widget slot (MRC retained). */
+static id _el_delegates[EL_UIKIT_MAX_WIDGETS];
+
+/* Allocate a slot and return its index. Returns -1 if full. */
+static int64_t el_widget_alloc(ElWidgetKind kind, id obj) {
+ for (int i = 1; i < EL_UIKIT_MAX_WIDGETS; i++) {
+ if (_el_widgets[i].kind == EL_WIDGET_FREE) {
+ _el_widgets[i].kind = kind;
+ _el_widgets[i].obj = [obj retain];
+ _el_widgets[i].cb_click = NULL;
+ _el_widgets[i].cb_change = NULL;
+ if (i > _el_widget_count) _el_widget_count = i;
+ return (int64_t)i;
+ }
+ }
+ return -1;
+}
+
+static ElWidget* el_widget_get(int64_t handle) {
+ if (handle <= 0 || handle >= EL_UIKIT_MAX_WIDGETS) return NULL;
+ if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
+ return &_el_widgets[handle];
+}
+
+static void el_widget_free(int64_t handle) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ [w->obj release];
+ w->obj = nil;
+ w->kind = EL_WIDGET_FREE;
+ free(w->cb_click); w->cb_click = NULL;
+ free(w->cb_change); w->cb_change = NULL;
+}
+
+/* ── Dispatch helpers ─────────────────────────────────────────────────────── */
+
+/* Run a block on the main thread, waiting for completion. Safe to call from
+ * any thread including the main thread. Uses dispatch_sync so callers can
+ * rely on the block having run before the function returns. */
+static void el_uikit_sync_main(void (^block)(void)) {
+ if ([NSThread isMainThread]) {
+ block();
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), block);
+ }
+}
+
+/* ── El callback invocation ──────────────────────────────────────────────── */
+/*
+ * Invoke an El callback by symbol name. The El function must have the
+ * signature: fn handler(handle: Int, data: String) -> Void
+ * which compiles to: void fn_name(el_val_t handle, el_val_t data)
+ */
+typedef void (*ElCb2)(int64_t handle, int64_t data);
+
+static void el_uikit_invoke_cb(const char* fn_name, int64_t handle, const char* data) {
+ if (!fn_name || !*fn_name) return;
+ void* sym = dlsym(RTLD_DEFAULT, fn_name);
+ if (!sym) return;
+ ElCb2 fn = (ElCb2)sym;
+ fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
+}
+
+/* ── ObjC helper: ElEventProxy — button tap and text event target ─────────── */
+
+/*
+ * ElEventProxy is a lightweight ObjC action target / delegate aggregator.
+ * One instance is allocated per widget that has events. Stored in _el_delegates[].
+ */
+@interface ElEventProxy : NSObject
+@property (nonatomic, assign) int64_t widgetHandle;
+/* UIButton tap */
+- (void)elButtonTapped:(id)sender;
+/* UITextField UIControlEventEditingChanged */
+- (void)elTextChanged:(id)sender;
+@end
+
+@implementation ElEventProxy
+
+- (void)elButtonTapped:(id)sender {
+ (void)sender;
+ ElWidget* w = el_widget_get(self.widgetHandle);
+ if (w && w->cb_click) {
+ el_uikit_invoke_cb(w->cb_click, self.widgetHandle, "");
+ }
+}
+
+- (void)elTextChanged:(id)sender {
+ UITextField* tf = (UITextField*)sender;
+ ElWidget* w = el_widget_get(self.widgetHandle);
+ if (!w) return;
+ const char* val = [tf.text UTF8String];
+ if (w->cb_change) {
+ el_uikit_invoke_cb(w->cb_change, self.widgetHandle, val ? val : "");
+ }
+}
+
+/* UITextFieldDelegate — return key fires on_submit (stored in cb_click). */
+- (BOOL)textFieldShouldReturn:(UITextField*)textField {
+ ElWidget* w = el_widget_get(self.widgetHandle);
+ if (w && w->cb_click) {
+ const char* val = [textField.text UTF8String];
+ el_uikit_invoke_cb(w->cb_click, self.widgetHandle, val ? val : "");
+ }
+ [textField resignFirstResponder];
+ return YES;
+}
+
+@end
+
+/* ── UITextView change delegate ──────────────────────────────────────────── */
+
+@interface ElTextViewDelegate : NSObject
+@property (nonatomic, assign) int64_t widgetHandle;
+@end
+
+@implementation ElTextViewDelegate
+
+- (void)textViewDidChange:(UITextView*)textView {
+ ElWidget* w = el_widget_get(self.widgetHandle);
+ if (!w || !w->cb_change) return;
+ const char* val = [textView.text UTF8String];
+ el_uikit_invoke_cb(w->cb_change, self.widgetHandle, val ? val : "");
+}
+
+@end
+
+/* ── iOS application lifecycle ───────────────────────────────────────────── */
+
+/*
+ * el_main_entry_fn — set by el's main() (via __native_run_loop) before calling
+ * UIApplicationMain. The app delegate invokes it in didFinishLaunching so that
+ * the el program's window/widget setup runs at the correct lifecycle point.
+ */
+void (*el_main_entry_fn)(void) = NULL;
+
+/* argc/argv forwarded from el_runtime_init_args (or main). */
+static int _el_argc = 0;
+static char** _el_argv = NULL;
+
+/*
+ * el_uikit_set_args — call from el_runtime_init_args / main() before
+ * __native_run_loop to forward argc/argv to UIApplicationMain.
+ */
+void el_uikit_set_args(int argc, char** argv) {
+ _el_argc = argc;
+ _el_argv = argv;
+}
+
+@interface ElAppDelegate : UIResponder
+@property (nonatomic, strong) UIWindow *keyWindow;
+@end
+
+@implementation ElAppDelegate
+
+- (BOOL)application:(UIApplication*)app
+ didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
+ (void)app; (void)launchOptions;
+ /*
+ * The el program called __native_init and set up el_main_entry_fn before
+ * UIApplicationMain was entered. We call it here so window creation and
+ * widget layout happen on the main thread at the right lifecycle moment.
+ */
+ if (el_main_entry_fn) {
+ el_main_entry_fn();
+ }
+ return YES;
+}
+
+- (void)applicationWillResignActive:(UIApplication*)app { (void)app; }
+- (void)applicationDidEnterBackground:(UIApplication*)app { (void)app; }
+- (void)applicationWillEnterForeground:(UIApplication*)app { (void)app; }
+- (void)applicationDidBecomeActive:(UIApplication*)app { (void)app; }
+
+@end
+
+/* ── Public C API ─────────────────────────────────────────────────────────── */
+
+/*
+ * el_uikit_init — one-time initialisation. Idempotent. Call before any other
+ * el_uikit_* function. On iOS there is no UIApplication equivalent of
+ * [NSApplication sharedApplication] to call here — the real initialisation
+ * happens inside UIApplicationMain which is entered from el_uikit_run_loop.
+ * This function records that init was requested.
+ */
+static int _el_uikit_init_done = 0;
+
+void el_uikit_init(void) {
+ _el_uikit_init_done = 1;
+}
+
+/*
+ * el_uikit_run_loop — enters UIApplicationMain. Never returns.
+ * Must be called from the process main thread as the final step of main().
+ * Sets el_main_entry_fn to the provided function pointer before entering
+ * UIApplicationMain so the app delegate can call back into el code.
+ */
+void el_uikit_run_loop(void (*entry_fn)(void)) {
+ el_main_entry_fn = entry_fn;
+ UIApplicationMain(_el_argc, _el_argv, nil, @"ElAppDelegate");
+}
+
+/*
+ * el_uikit_window_create — create a UIWindow and configure a root
+ * UINavigationController + UIViewController. Returns window handle.
+ *
+ * On iOS there is exactly one full-screen window. width/height from the
+ * manifest are stored but the window always fills the screen. The title
+ * is applied to the root view controller's navigationItem.
+ */
+int64_t el_uikit_window_create(const char* title, int width, int height,
+ int min_width, int min_height) {
+ (void)width; (void)height; (void)min_width; (void)min_height;
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIScreen* screen = [UIScreen mainScreen];
+ UIWindow* win = [[UIWindow alloc] initWithFrame:[screen bounds]];
+ win.backgroundColor = [UIColor systemBackgroundColor];
+
+ /* Root view controller with a vertical UIStackView as content. */
+ UIViewController* rootVC = [[UIViewController alloc] init];
+ NSString* titleStr = [NSString stringWithUTF8String:title ? title : ""];
+ rootVC.title = titleStr;
+
+ UIStackView* rootStack = [[UIStackView alloc] initWithFrame:CGRectZero];
+ rootStack.axis = UILayoutConstraintAxisVertical;
+ rootStack.alignment = UIStackViewAlignmentFill;
+ rootStack.distribution = UIStackViewDistributionFill;
+ rootStack.spacing = 0.0;
+ rootStack.translatesAutoresizingMaskIntoConstraints = NO;
+ [rootVC.view addSubview:rootStack];
+
+ /* Pin root stack to safe area. */
+ UILayoutGuide* safe = rootVC.view.safeAreaLayoutGuide;
+ [NSLayoutConstraint activateConstraints:@[
+ [rootStack.topAnchor constraintEqualToAnchor:safe.topAnchor],
+ [rootStack.leadingAnchor constraintEqualToAnchor:safe.leadingAnchor],
+ [rootStack.trailingAnchor constraintEqualToAnchor:safe.trailingAnchor],
+ [rootStack.bottomAnchor constraintEqualToAnchor:safe.bottomAnchor],
+ ]];
+
+ UINavigationController* navCtrl =
+ [[UINavigationController alloc] initWithRootViewController:rootVC];
+ win.rootViewController = navCtrl;
+
+ /* Store the window. The root stack is accessible via
+ * ((UINavigationController*)win.rootViewController)
+ * .topViewController.view.subviews[0]
+ * but we reach it by casting obj to UIWindow* and digging at add_child time. */
+ handle = el_widget_alloc(EL_WIDGET_WINDOW, win);
+ [win release];
+ [rootVC release];
+ [rootStack release];
+ [navCtrl release];
+ });
+ return handle;
+}
+
+/*
+ * el_uikit_window_show — make the window key and visible.
+ */
+void el_uikit_window_show(int64_t handle) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind != EL_WIDGET_WINDOW) return;
+ UIWindow* win = (UIWindow*)w->obj;
+ [win makeKeyAndVisible];
+ });
+}
+
+/*
+ * el_uikit_window_set_title — update nav bar title at runtime.
+ */
+void el_uikit_window_set_title(int64_t handle, const char* title) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind != EL_WIDGET_WINDOW) return;
+ UIWindow* win = (UIWindow*)w->obj;
+ UINavigationController* nav = (UINavigationController*)win.rootViewController;
+ if ([nav isKindOfClass:[UINavigationController class]]) {
+ nav.topViewController.title =
+ [NSString stringWithUTF8String:title ? title : ""];
+ }
+ });
+}
+
+/* ── Layout container factories ───────────────────────────────────────────── */
+
+static int64_t make_uistack(UILayoutConstraintAxis axis, CGFloat spacing) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIStackView* sv = [[UIStackView alloc] initWithFrame:CGRectZero];
+ sv.axis = axis;
+ sv.alignment = UIStackViewAlignmentFill;
+ sv.distribution = UIStackViewDistributionFill;
+ sv.spacing = spacing;
+ sv.translatesAutoresizingMaskIntoConstraints = NO;
+ ElWidgetKind kind = (axis == UILayoutConstraintAxisVertical)
+ ? EL_WIDGET_VSTACK : EL_WIDGET_HSTACK;
+ handle = el_widget_alloc(kind, sv);
+ [sv release];
+ });
+ return handle;
+}
+
+int64_t el_uikit_vstack_create(int spacing) {
+ return make_uistack(UILayoutConstraintAxisVertical, (CGFloat)spacing);
+}
+
+int64_t el_uikit_hstack_create(int spacing) {
+ return make_uistack(UILayoutConstraintAxisHorizontal, (CGFloat)spacing);
+}
+
+int64_t el_uikit_zstack_create(void) {
+ /* ZStack = plain UIView; children are added with addSubview and use
+ * Auto Layout or absolute frames set by the caller. */
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIView* v = [[UIView alloc] initWithFrame:CGRectZero];
+ v.translatesAutoresizingMaskIntoConstraints = NO;
+ handle = el_widget_alloc(EL_WIDGET_ZSTACK, v);
+ [v release];
+ });
+ return handle;
+}
+
+int64_t el_uikit_scroll_create(void) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIScrollView* sv = [[UIScrollView alloc] initWithFrame:CGRectZero];
+ sv.translatesAutoresizingMaskIntoConstraints = NO;
+ sv.alwaysBounceVertical = YES;
+ sv.showsHorizontalScrollIndicator = NO;
+ handle = el_widget_alloc(EL_WIDGET_SCROLL, sv);
+ [sv release];
+ });
+ return handle;
+}
+
+/* ── Widget factories ─────────────────────────────────────────────────────── */
+
+int64_t el_uikit_label_create(const char* text) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UILabel* lbl = [[UILabel alloc] initWithFrame:CGRectZero];
+ lbl.text = [NSString stringWithUTF8String:text ? text : ""];
+ lbl.numberOfLines = 0;
+ lbl.translatesAutoresizingMaskIntoConstraints = NO;
+ handle = el_widget_alloc(EL_WIDGET_LABEL, lbl);
+ [lbl release];
+ });
+ return handle;
+}
+
+int64_t el_uikit_button_create(const char* label) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIButton* btn = [UIButton buttonWithType:UIButtonTypeSystem];
+ [btn setTitle:[NSString stringWithUTF8String:label ? label : ""]
+ forState:UIControlStateNormal];
+ btn.translatesAutoresizingMaskIntoConstraints = NO;
+
+ ElEventProxy* proxy = [[ElEventProxy alloc] init];
+ [btn addTarget:proxy
+ action:@selector(elButtonTapped:)
+ forControlEvents:UIControlEventTouchUpInside];
+
+ int64_t h = el_widget_alloc(EL_WIDGET_BUTTON, btn);
+ proxy.widgetHandle = h;
+ _el_delegates[h] = proxy; /* retain in side table */
+ handle = h;
+ /* btn is autoreleased from buttonWithType: — no release needed */
+ });
+ return handle;
+}
+
+int64_t el_uikit_text_field_create(const char* placeholder) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UITextField* tf = [[UITextField alloc] initWithFrame:CGRectZero];
+ tf.translatesAutoresizingMaskIntoConstraints = NO;
+ if (placeholder && *placeholder) {
+ tf.placeholder = [NSString stringWithUTF8String:placeholder];
+ }
+ tf.borderStyle = UITextBorderStyleRoundedRect;
+ tf.autocorrectionType = UITextAutocorrectionTypeNo;
+
+ ElEventProxy* proxy = [[ElEventProxy alloc] init];
+ tf.delegate = proxy;
+ [tf addTarget:proxy
+ action:@selector(elTextChanged:)
+ forControlEvents:UIControlEventEditingChanged];
+
+ int64_t h = el_widget_alloc(EL_WIDGET_TEXTFIELD, tf);
+ proxy.widgetHandle = h;
+ _el_delegates[h] = proxy;
+ handle = h;
+ [tf release];
+ });
+ return handle;
+}
+
+int64_t el_uikit_text_area_create(const char* placeholder) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UITextView* tv = [[UITextView alloc] initWithFrame:CGRectZero];
+ tv.translatesAutoresizingMaskIntoConstraints = NO;
+ tv.font = [UIFont systemFontOfSize:14.0];
+ tv.layer.borderColor = [[UIColor systemGray4Color] CGColor];
+ tv.layer.borderWidth = 1.0;
+ tv.layer.cornerRadius = 6.0;
+ tv.clipsToBounds = YES;
+ /* UITextView has no native placeholder; set accessibility hint. */
+ if (placeholder && *placeholder) {
+ tv.accessibilityHint = [NSString stringWithUTF8String:placeholder];
+ }
+
+ ElTextViewDelegate* del = [[ElTextViewDelegate alloc] init];
+ tv.delegate = del;
+
+ int64_t h = el_widget_alloc(EL_WIDGET_TEXTAREA, tv);
+ del.widgetHandle = h;
+ _el_delegates[h] = del;
+ handle = h;
+ [tv release];
+ });
+ return handle;
+}
+
+int64_t el_uikit_image_create(const char* path_or_name) {
+ __block int64_t handle = -1;
+ el_uikit_sync_main(^{
+ UIImage* img = nil;
+ if (path_or_name && *path_or_name) {
+ /* Try filesystem path first, then bundle asset name. */
+ img = [UIImage imageWithContentsOfFile:
+ [NSString stringWithUTF8String:path_or_name]];
+ if (!img) {
+ img = [UIImage imageNamed:
+ [NSString stringWithUTF8String:path_or_name]];
+ }
+ }
+ UIImageView* iv = [[UIImageView alloc] initWithFrame:CGRectZero];
+ iv.translatesAutoresizingMaskIntoConstraints = NO;
+ iv.contentMode = UIViewContentModeScaleAspectFit;
+ if (img) {
+ iv.image = img;
+ }
+ handle = el_widget_alloc(EL_WIDGET_IMAGE, iv);
+ [iv release];
+ });
+ return handle;
+}
+
+/* ── Widget property setters ─────────────────────────────────────────────── */
+
+void el_uikit_widget_set_text(int64_t handle, const char* text) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ NSString* s = [NSString stringWithUTF8String:text ? text : ""];
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ ((UILabel*)w->obj).text = s;
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ ((UITextField*)w->obj).text = s;
+ break;
+ case EL_WIDGET_BUTTON:
+ [(UIButton*)w->obj setTitle:s forState:UIControlStateNormal];
+ break;
+ case EL_WIDGET_TEXTAREA:
+ ((UITextView*)w->obj).text = s;
+ break;
+ case EL_WIDGET_WINDOW:
+ el_uikit_window_set_title(handle, text);
+ break;
+ default: break;
+ }
+ });
+}
+
+const char* el_uikit_widget_get_text(int64_t handle) {
+ __block const char* result = "";
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ NSString* s = nil;
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ s = ((UILabel*)w->obj).text;
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ s = ((UITextField*)w->obj).text;
+ break;
+ case EL_WIDGET_BUTTON:
+ s = [(UIButton*)w->obj titleForState:UIControlStateNormal];
+ break;
+ case EL_WIDGET_TEXTAREA:
+ s = ((UITextView*)w->obj).text;
+ break;
+ default: break;
+ }
+ if (s) result = strdup([s UTF8String]);
+ });
+ return result;
+}
+
+void el_uikit_widget_set_color(int64_t handle, float r, float g, float b, float a) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ UIColor* c = [UIColor colorWithRed:(CGFloat)(r / 255.0)
+ green:(CGFloat)(g / 255.0)
+ blue:(CGFloat)(b / 255.0)
+ alpha:(CGFloat)(a / 255.0)];
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ ((UILabel*)w->obj).textColor = c;
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ ((UITextField*)w->obj).textColor = c;
+ break;
+ case EL_WIDGET_TEXTAREA:
+ ((UITextView*)w->obj).textColor = c;
+ break;
+ case EL_WIDGET_BUTTON:
+ [(UIButton*)w->obj setTitleColor:c forState:UIControlStateNormal];
+ ((UIButton*)w->obj).tintColor = c;
+ break;
+ case EL_WIDGET_VSTACK:
+ case EL_WIDGET_HSTACK:
+ case EL_WIDGET_ZSTACK:
+ ((UIView*)w->obj).backgroundColor = c;
+ break;
+ default: break;
+ }
+ });
+}
+
+void el_uikit_widget_set_bg_color(int64_t handle, float r, float g, float b, float a) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ if (w->kind == EL_WIDGET_WINDOW) {
+ UIWindow* win = (UIWindow*)w->obj;
+ win.backgroundColor = [UIColor colorWithRed:(CGFloat)(r / 255.0)
+ green:(CGFloat)(g / 255.0)
+ blue:(CGFloat)(b / 255.0)
+ alpha:(CGFloat)(a / 255.0)];
+ return;
+ }
+ UIView* v = (UIView*)w->obj;
+ v.backgroundColor = [UIColor colorWithRed:(CGFloat)(r / 255.0)
+ green:(CGFloat)(g / 255.0)
+ blue:(CGFloat)(b / 255.0)
+ alpha:(CGFloat)(a / 255.0)];
+ });
+}
+
+void el_uikit_widget_set_font(int64_t handle, const char* family, int size, int bold) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ UIFont* font;
+ if (bold) {
+ font = [UIFont boldSystemFontOfSize:(CGFloat)size];
+ } else if (family && *family && strcmp(family, "system") != 0) {
+ font = [UIFont fontWithName:[NSString stringWithUTF8String:family]
+ size:(CGFloat)size];
+ if (!font) font = [UIFont systemFontOfSize:(CGFloat)size];
+ } else {
+ font = [UIFont systemFontOfSize:(CGFloat)size];
+ }
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ ((UILabel*)w->obj).font = font;
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ ((UITextField*)w->obj).font = font;
+ break;
+ case EL_WIDGET_TEXTAREA:
+ ((UITextView*)w->obj).font = font;
+ break;
+ case EL_WIDGET_BUTTON:
+ ((UIButton*)w->obj).titleLabel.font = font;
+ break;
+ default: break;
+ }
+ });
+}
+
+void el_uikit_widget_set_padding(int64_t handle, int top, int right, int bottom, int left) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ UIEdgeInsets insets = UIEdgeInsetsMake(
+ (CGFloat)top, (CGFloat)left, (CGFloat)bottom, (CGFloat)right);
+ if (w->kind == EL_WIDGET_VSTACK || w->kind == EL_WIDGET_HSTACK) {
+ UIStackView* sv = (UIStackView*)w->obj;
+ sv.layoutMargins = insets;
+ sv.layoutMarginsRelativeArrangement = YES;
+ } else if (w->kind == EL_WIDGET_TEXTAREA) {
+ UITextView* tv = (UITextView*)w->obj;
+ tv.textContainerInset = insets;
+ } else {
+ UIView* v = (UIView*)w->obj;
+ v.layoutMargins = insets;
+ }
+ });
+}
+
+void el_uikit_widget_set_width(int64_t handle, int width) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ UIView* v = (UIView*)w->obj;
+ NSLayoutConstraint* c = [v.widthAnchor
+ constraintEqualToConstant:(CGFloat)width];
+ c.priority = UILayoutPriorityDefaultHigh;
+ c.active = YES;
+ });
+}
+
+void el_uikit_widget_set_height(int64_t handle, int height) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ UIView* v = (UIView*)w->obj;
+ NSLayoutConstraint* c = [v.heightAnchor
+ constraintEqualToConstant:(CGFloat)height];
+ c.priority = UILayoutPriorityDefaultHigh;
+ c.active = YES;
+ });
+}
+
+void el_uikit_widget_set_flex(int64_t handle, int flex) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ UIView* v = (UIView*)w->obj;
+ /* flex > 0 → low hugging priority so the view expands to fill space.
+ * flex == 0 → high hugging priority so the view stays at intrinsic size. */
+ UILayoutPriority p = (flex > 0)
+ ? UILayoutPriorityDefaultLow
+ : UILayoutPriorityDefaultHigh;
+ [v setContentHuggingPriority:p
+ forAxis:UILayoutConstraintAxisHorizontal];
+ [v setContentHuggingPriority:p
+ forAxis:UILayoutConstraintAxisVertical];
+ });
+}
+
+void el_uikit_widget_set_corner_radius(int64_t handle, int radius) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ UIView* v = (UIView*)w->obj;
+ v.layer.cornerRadius = (CGFloat)radius;
+ v.clipsToBounds = YES;
+ });
+}
+
+void el_uikit_widget_set_disabled(int64_t handle, int disabled) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ switch (w->kind) {
+ case EL_WIDGET_BUTTON:
+ ((UIButton*)w->obj).enabled = !disabled;
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ ((UITextField*)w->obj).enabled = !disabled;
+ break;
+ case EL_WIDGET_TEXTAREA:
+ ((UITextView*)w->obj).editable = !disabled;
+ break;
+ default:
+ ((UIView*)w->obj).userInteractionEnabled = !disabled;
+ break;
+ }
+ });
+}
+
+void el_uikit_widget_set_hidden(int64_t handle, int hidden) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ ((UIView*)w->obj).hidden = (BOOL)hidden;
+ });
+}
+
+/* ── Child management ─────────────────────────────────────────────────────── */
+
+/*
+ * el_uikit_widget_add_child — attach child widget to parent.
+ *
+ * - Window: child is added to the root UIStackView inside the root VC.
+ * - VStack/HStack: child is added as an arranged subview.
+ * - ZStack: child is added as a plain subview (caller manages layout).
+ * - ScrollView: child is added as a subview and pinned to content layout guide.
+ * - Generic view: child is added as a plain subview.
+ */
+void el_uikit_widget_add_child(int64_t parent, int64_t child) {
+ el_uikit_sync_main(^{
+ ElWidget* pw = el_widget_get(parent);
+ ElWidget* cw = el_widget_get(child);
+ if (!pw || !cw) return;
+ if (cw->kind == EL_WIDGET_WINDOW) return; /* can't add window as subview */
+
+ UIView* childView = (UIView*)cw->obj;
+
+ switch (pw->kind) {
+ case EL_WIDGET_WINDOW: {
+ /* Dig to the root stack view: window → navCtrl → topVC → view → subviews[0] */
+ UIWindow* win = (UIWindow*)pw->obj;
+ UINavigationController* nav =
+ (UINavigationController*)win.rootViewController;
+ UIView* rootVCView = nav.topViewController.view;
+ UIStackView* stack = nil;
+ for (UIView* sub in rootVCView.subviews) {
+ if ([sub isKindOfClass:[UIStackView class]]) {
+ stack = (UIStackView*)sub;
+ break;
+ }
+ }
+ if (stack) {
+ [stack addArrangedSubview:childView];
+ } else {
+ [rootVCView addSubview:childView];
+ }
+ break;
+ }
+ case EL_WIDGET_VSTACK:
+ case EL_WIDGET_HSTACK: {
+ UIStackView* sv = (UIStackView*)pw->obj;
+ [sv addArrangedSubview:childView];
+ break;
+ }
+ case EL_WIDGET_SCROLL: {
+ UIScrollView* sv = (UIScrollView*)pw->obj;
+ [sv addSubview:childView];
+ /* Pin child to content layout guide so scroll view knows content size. */
+ UILayoutGuide* cg = sv.contentLayoutGuide;
+ childView.translatesAutoresizingMaskIntoConstraints = NO;
+ [NSLayoutConstraint activateConstraints:@[
+ [childView.topAnchor constraintEqualToAnchor:cg.topAnchor],
+ [childView.leadingAnchor constraintEqualToAnchor:cg.leadingAnchor],
+ [childView.trailingAnchor constraintEqualToAnchor:cg.trailingAnchor],
+ [childView.bottomAnchor constraintEqualToAnchor:cg.bottomAnchor],
+ /* Width matches scroll view's frame (vertical scroll only). */
+ [childView.widthAnchor constraintEqualToAnchor:sv.frameLayoutGuide.widthAnchor],
+ ]];
+ break;
+ }
+ case EL_WIDGET_ZSTACK:
+ default: {
+ UIView* pv = (UIView*)pw->obj;
+ [pv addSubview:childView];
+ break;
+ }
+ }
+ });
+}
+
+/*
+ * el_uikit_widget_remove_child — detach a child widget from its parent.
+ */
+void el_uikit_widget_remove_child(int64_t parent, int64_t child) {
+ el_uikit_sync_main(^{
+ ElWidget* pw = el_widget_get(parent);
+ ElWidget* cw = el_widget_get(child);
+ if (!cw || cw->kind == EL_WIDGET_WINDOW) return;
+ UIView* childView = (UIView*)cw->obj;
+ /* If parent is a stack view, remove from arranged subviews too. */
+ if (pw && (pw->kind == EL_WIDGET_VSTACK || pw->kind == EL_WIDGET_HSTACK)) {
+ UIStackView* sv = (UIStackView*)pw->obj;
+ [sv removeArrangedSubview:childView];
+ } else if (pw && pw->kind == EL_WIDGET_WINDOW) {
+ /* Find and remove from root stack. */
+ UIWindow* win = (UIWindow*)pw->obj;
+ UINavigationController* nav =
+ (UINavigationController*)win.rootViewController;
+ for (UIView* sub in nav.topViewController.view.subviews) {
+ if ([sub isKindOfClass:[UIStackView class]]) {
+ [(UIStackView*)sub removeArrangedSubview:childView];
+ break;
+ }
+ }
+ }
+ [childView removeFromSuperview];
+ });
+}
+
+/* ── Event registration ───────────────────────────────────────────────────── */
+
+void el_uikit_widget_on_click(int64_t handle, const char* fn_name) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_click);
+ w->cb_click = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+}
+
+void el_uikit_widget_on_change(int64_t handle, const char* fn_name) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_change);
+ w->cb_change = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+}
+
+void el_uikit_widget_on_submit(int64_t handle, const char* fn_name) {
+ /* For text fields, submit = return key → stored in cb_click slot. */
+ el_uikit_widget_on_click(handle, fn_name);
+}
+
+/* ── Widget destroy ───────────────────────────────────────────────────────── */
+
+void el_uikit_widget_destroy(int64_t handle) {
+ el_uikit_sync_main(^{
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ if (w->kind != EL_WIDGET_WINDOW) {
+ UIView* v = (UIView*)w->obj;
+ /* If it's in a stack view, also remove from arranged subviews. */
+ if ([v.superview isKindOfClass:[UIStackView class]]) {
+ [(UIStackView*)v.superview removeArrangedSubview:v];
+ }
+ [v removeFromSuperview];
+ }
+ [_el_delegates[handle] release];
+ _el_delegates[handle] = nil;
+ el_widget_free(handle);
+ });
+}
+
+/* ── Manifest reader ──────────────────────────────────────────────────────── */
+
+/*
+ * el_uikit_manifest_read — read an app manifest file. Tries the given path
+ * first; if not found, looks in the main bundle. Returns a C string with the
+ * file contents (heap-allocated; caller must not free — matches AppKit bridge
+ * convention of returning static/permanent lifetime strings from manifest).
+ * Returns NULL on failure.
+ */
+const char* el_uikit_manifest_read(const char* path) {
+ if (!path || !*path) return NULL;
+
+ NSString* pathStr = [NSString stringWithUTF8String:path];
+ NSString* content = [NSString stringWithContentsOfFile:pathStr
+ encoding:NSUTF8StringEncoding
+ error:nil];
+ if (!content) {
+ /* Try bundle resource lookup — strip to last component without extension. */
+ NSString* baseName = [[pathStr lastPathComponent]
+ stringByDeletingPathExtension];
+ NSString* ext = [pathStr pathExtension];
+ NSString* bundlePath = [[NSBundle mainBundle]
+ pathForResource:baseName ofType:(ext.length ? ext : nil)];
+ if (bundlePath) {
+ content = [NSString stringWithContentsOfFile:bundlePath
+ encoding:NSUTF8StringEncoding
+ error:nil];
+ }
+ }
+ if (!content) return NULL;
+ return strdup([content UTF8String]);
+}
+
+/* ── __widget_* C API (called from el_seed.c) ─────────────────────────────── */
+/*
+ * These are the symbols that el compiled programs actually call. They forward
+ * directly to the el_uikit_* implementations above. el_val_t is int64_t (the
+ * same type used by the AppKit bridge).
+ */
+
+void __native_init(void) {
+ el_uikit_init();
+}
+
+void __native_run_loop(void) {
+ /* el_main_entry_fn must be set by the caller before this. On iOS the
+ * entry function pointer pattern is inverted vs macOS: the el program
+ * packages up its "build UI" work into a lambda / function and registers
+ * it via el_main_entry_fn, then calls __native_run_loop.
+ * el_uikit_run_loop takes that fn pointer and passes it to UIApplicationMain. */
+ el_uikit_run_loop(el_main_entry_fn);
+}
+
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height) {
+ return (el_val_t)el_uikit_window_create(
+ (const char*)(uintptr_t)title,
+ (int)width, (int)height,
+ (int)min_width, (int)min_height);
+}
+
+void __window_show(el_val_t handle) {
+ el_uikit_window_show((int64_t)handle);
+}
+
+void __window_set_title(el_val_t handle, el_val_t title) {
+ el_uikit_window_set_title((int64_t)handle,
+ (const char*)(uintptr_t)title);
+}
+
+el_val_t __vstack_create(el_val_t spacing) {
+ return (el_val_t)el_uikit_vstack_create((int)spacing);
+}
+
+el_val_t __hstack_create(el_val_t spacing) {
+ return (el_val_t)el_uikit_hstack_create((int)spacing);
+}
+
+el_val_t __zstack_create(void) {
+ return (el_val_t)el_uikit_zstack_create();
+}
+
+el_val_t __scroll_create(void) {
+ return (el_val_t)el_uikit_scroll_create();
+}
+
+el_val_t __label_create(el_val_t text) {
+ return (el_val_t)el_uikit_label_create((const char*)(uintptr_t)text);
+}
+
+el_val_t __button_create(el_val_t label) {
+ return (el_val_t)el_uikit_button_create((const char*)(uintptr_t)label);
+}
+
+el_val_t __text_field_create(el_val_t placeholder) {
+ return (el_val_t)el_uikit_text_field_create(
+ (const char*)(uintptr_t)placeholder);
+}
+
+el_val_t __text_area_create(el_val_t placeholder) {
+ return (el_val_t)el_uikit_text_area_create(
+ (const char*)(uintptr_t)placeholder);
+}
+
+el_val_t __image_create(el_val_t path_or_name) {
+ return (el_val_t)el_uikit_image_create(
+ (const char*)(uintptr_t)path_or_name);
+}
+
+void __widget_set_text(el_val_t handle, el_val_t text) {
+ el_uikit_widget_set_text((int64_t)handle, (const char*)(uintptr_t)text);
+}
+
+el_val_t __widget_get_text(el_val_t handle) {
+ return (el_val_t)(uintptr_t)el_uikit_widget_get_text((int64_t)handle);
+}
+
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a) {
+ el_uikit_widget_set_color((int64_t)handle,
+ (float)r, (float)g, (float)b, (float)a);
+}
+
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a) {
+ el_uikit_widget_set_bg_color((int64_t)handle,
+ (float)r, (float)g, (float)b, (float)a);
+}
+
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold) {
+ el_uikit_widget_set_font((int64_t)handle,
+ (const char*)(uintptr_t)family, (int)size, (int)bold);
+}
+
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left) {
+ el_uikit_widget_set_padding((int64_t)handle,
+ (int)top, (int)right, (int)bottom, (int)left);
+}
+
+void __widget_set_width(el_val_t handle, el_val_t width) {
+ el_uikit_widget_set_width((int64_t)handle, (int)width);
+}
+
+void __widget_set_height(el_val_t handle, el_val_t height) {
+ el_uikit_widget_set_height((int64_t)handle, (int)height);
+}
+
+void __widget_set_flex(el_val_t handle, el_val_t flex) {
+ el_uikit_widget_set_flex((int64_t)handle, (int)flex);
+}
+
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius) {
+ el_uikit_widget_set_corner_radius((int64_t)handle, (int)radius);
+}
+
+void __widget_set_disabled(el_val_t handle, el_val_t disabled) {
+ el_uikit_widget_set_disabled((int64_t)handle, (int)disabled);
+}
+
+void __widget_set_hidden(el_val_t handle, el_val_t hidden) {
+ el_uikit_widget_set_hidden((int64_t)handle, (int)hidden);
+}
+
+void __widget_add_child(el_val_t parent, el_val_t child) {
+ el_uikit_widget_add_child((int64_t)parent, (int64_t)child);
+}
+
+void __widget_remove_child(el_val_t parent, el_val_t child) {
+ el_uikit_widget_remove_child((int64_t)parent, (int64_t)child);
+}
+
+void __widget_destroy(el_val_t handle) {
+ el_uikit_widget_destroy((int64_t)handle);
+}
+
+void __widget_on_click(el_val_t handle, el_val_t fn_name) {
+ el_uikit_widget_on_click((int64_t)handle,
+ (const char*)(uintptr_t)fn_name);
+}
+
+void __widget_on_change(el_val_t handle, el_val_t fn_name) {
+ el_uikit_widget_on_change((int64_t)handle,
+ (const char*)(uintptr_t)fn_name);
+}
+
+void __widget_on_submit(el_val_t handle, el_val_t fn_name) {
+ el_uikit_widget_on_submit((int64_t)handle,
+ (const char*)(uintptr_t)fn_name);
+}
+
+el_val_t __manifest_read(el_val_t path) {
+ return (el_val_t)(uintptr_t)el_uikit_manifest_read(
+ (const char*)(uintptr_t)path);
+}
+
+#endif /* EL_TARGET_IOS */
diff --git a/ui/examples/native-hello-ios/NativeHello/main.m b/ui/examples/native-hello-ios/NativeHello/main.m
new file mode 100644
index 0000000..4ca6b86
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/main.m
@@ -0,0 +1,48 @@
+/*
+ * main.m — entry point for native-hello-ios.
+ *
+ * iOS UIKit lifecycle:
+ * UIApplicationMain never returns. The compiled el program's boot body
+ * (native_init → window_from_manifest → app_build → window_show) must
+ * run inside applicationDidFinishLaunchingWithOptions on the main thread.
+ *
+ * How it works:
+ * 1. el_uikit_set_args() forwards argc/argv to UIApplicationMain.
+ * 2. el_main_entry_fn is set to el_app_boot (defined below) before
+ * UIApplicationMain is entered.
+ * 3. ElAppDelegate.didFinishLaunchingWithOptions calls el_main_entry_fn(),
+ * which runs the el program's widget setup at the correct lifecycle point.
+ * 4. __native_run_loop() inside the compiled el main is a no-op on iOS —
+ * UIApplicationMain already owns the run loop.
+ *
+ * The generated native_hello.c exports el_app_main() (renamed from main by
+ * the gen step in build.sh). We call it from el_app_boot so it runs after
+ * UIApplicationMain sets up the application object.
+ *
+ * Compile with -fno-objc-arc (required by el_uikit.m).
+ */
+
+#import
+
+/* From el_uikit.m */
+void el_uikit_set_args(int argc, char **argv);
+extern void (*el_main_entry_fn)(void);
+
+/* Forward declaration of the renamed compiled el main. */
+int el_app_main(int argc, char **argv);
+
+/* Boot function registered as el_main_entry_fn. Called by ElAppDelegate
+ * inside didFinishLaunchingWithOptions on the main thread. */
+static void el_app_boot(void) {
+ char *argv[] = {"el-app", NULL};
+ el_app_main(1, argv);
+}
+
+int main(int argc, char *argv[]) {
+ el_uikit_set_args(argc, argv);
+ /* Register the boot function so ElAppDelegate can call it. */
+ el_main_entry_fn = el_app_boot;
+ @autoreleasepool {
+ return UIApplicationMain(argc, argv, nil, @"ElAppDelegate");
+ }
+}
diff --git a/ui/examples/native-hello-ios/NativeHello/native_hello.c b/ui/examples/native-hello-ios/NativeHello/native_hello.c
new file mode 100644
index 0000000..e18f274
--- /dev/null
+++ b/ui/examples/native-hello-ios/NativeHello/native_hello.c
@@ -0,0 +1,123 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data);
+el_val_t on_counter_click(el_val_t widget, el_val_t data);
+el_val_t on_name_change(el_val_t widget, el_val_t data);
+el_val_t app_build(el_val_t window);
+
+el_val_t g_window;
+el_val_t g_label;
+el_val_t g_input;
+el_val_t g_button;
+el_val_t g_counter;
+el_val_t g_counter_lbl;
+el_val_t manifest_env;
+el_val_t manifest_path;
+el_val_t win;
+el_val_t g_window;
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data) {
+ el_val_t name = widget_get_text(g_input);
+ el_val_t greeting = ({ el_val_t _if_result_1 = 0; if ((str_len(name) > 0)) { _if_result_1 = (el_str_concat(el_str_concat(EL_STR("Hello, "), name), EL_STR("!"))); } else { _if_result_1 = (EL_STR("Hello, World!")); } _if_result_1; });
+ widget_set_text(g_label, greeting);
+ return 0;
+}
+
+el_val_t on_counter_click(el_val_t widget, el_val_t data) {
+ el_val_t g_counter = (g_counter + 1);
+ widget_set_text(g_counter_lbl, el_str_concat(EL_STR("Clicks: "), int_to_str(g_counter)));
+ return 0;
+}
+
+el_val_t on_name_change(el_val_t widget, el_val_t data) {
+ el_val_t has_text = (str_len(data) > 0);
+ widget_set_disabled(g_button, !has_text);
+ return 0;
+}
+
+el_val_t app_build(el_val_t window) {
+ el_val_t root = vstack(0);
+ widget_set_padding_all(root, 24);
+ widget_set_bg_color_hex(root, EL_STR("#0f172a"));
+ widget_set_flex(root, 1);
+ el_val_t title = label(EL_STR("el-native \xe2\x80\x94 native widget demo"));
+ style_label_heading(title);
+ widget_add_child(root, title);
+ el_val_t gap1 = label(EL_STR(""));
+ widget_set_height(gap1, 16);
+ widget_add_child(root, gap1);
+ el_val_t subtitle = label(EL_STR("AppKit controls from el code, no ObjC in the app layer."));
+ style_label_muted(subtitle);
+ widget_add_child(root, subtitle);
+ el_val_t gap2 = label(EL_STR(""));
+ widget_set_height(gap2, 24);
+ widget_add_child(root, gap2);
+ el_val_t input_row = hstack(8);
+ el_val_t name_label = label(EL_STR("Name:"));
+ style_label_body(name_label);
+ widget_set_width(name_label, 60);
+ el_val_t input = text_field(EL_STR("Enter your name\xe2\x80\xa6"));
+ style_label_body(input);
+ widget_set_flex(input, 1);
+ widget_on_change(input, EL_STR("on_name_change"));
+ el_val_t g_input = input;
+ el_val_t greet_btn = button(EL_STR("Greet"));
+ style_button_primary(greet_btn);
+ widget_set_disabled(greet_btn, 1);
+ widget_on_click(greet_btn, EL_STR("on_greet_click"));
+ el_val_t g_button = greet_btn;
+ widget_add_child(input_row, name_label);
+ widget_add_child(input_row, input);
+ widget_add_child(input_row, greet_btn);
+ widget_add_child(root, input_row);
+ el_val_t gap3 = label(EL_STR(""));
+ widget_set_height(gap3, 12);
+ widget_add_child(root, gap3);
+ el_val_t greeting = label(EL_STR("Waiting for name\xe2\x80\xa6"));
+ style_label_body(greeting);
+ widget_set_color_hex(greeting, EL_STR("#60a5fa"));
+ widget_set_font(greeting, EL_STR("system"), 16, 1);
+ el_val_t g_label = greeting;
+ widget_add_child(root, greeting);
+ el_val_t gap4 = label(EL_STR(""));
+ widget_set_height(gap4, 24);
+ widget_add_child(root, gap4);
+ el_val_t counter_row = hstack(12);
+ el_val_t counter_lbl = label(EL_STR("Clicks: 0"));
+ style_label_body(counter_lbl);
+ el_val_t g_counter_lbl = counter_lbl;
+ el_val_t counter_btn = button(EL_STR("Click me"));
+ style_button_primary(counter_btn);
+ widget_on_click(counter_btn, EL_STR("on_counter_click"));
+ widget_add_child(counter_row, counter_lbl);
+ widget_add_child(counter_row, counter_btn);
+ widget_add_child(root, counter_row);
+ widget_add_child(window, root);
+ return 0;
+}
+
+int el_app_main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ g_window = (-1);
+ g_label = (-1);
+ g_input = (-1);
+ g_button = (-1);
+ g_counter = 0;
+ g_counter_lbl = (-1);
+ native_init();
+ manifest_env = env(EL_STR("EL_MANIFEST"));
+ manifest_path = ({ el_val_t _if_result_2 = 0; if ((str_len(manifest_env) > 0)) { _if_result_2 = (manifest_env); } else { _if_result_2 = (EL_STR("manifest.el")); } _if_result_2; });
+ win = window_from_manifest(manifest_path);
+ g_window = win;
+ if (win < 0) {
+ println(EL_STR("Error: failed to create window. Is EL_TARGET_MACOS defined?"));
+ exit_program(1);
+ }
+ app_build(win);
+ window_show(win);
+ native_run_loop();
+ return 0;
+}
+
diff --git a/ui/examples/native-hello-ios/build.sh b/ui/examples/native-hello-ios/build.sh
new file mode 100755
index 0000000..3c1d92d
--- /dev/null
+++ b/ui/examples/native-hello-ios/build.sh
@@ -0,0 +1,136 @@
+#!/usr/bin/env bash
+# build.sh — Build and install native-hello-ios to the iOS Simulator.
+#
+# Usage:
+# ./build.sh # gen C sources + xcodebuild for simulator
+# ./build.sh gen # regenerate C sources only
+# ./build.sh build # xcodebuild only (skip elc step)
+# ./build.sh install # gen + build + boot simulator + install + launch
+# ./build.sh clean # remove build/ directory
+#
+# Requirements:
+# - Xcode with iOS Simulator SDK installed
+# - elc at ../../../lang/dist/platform/elc (or in PATH)
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+EL_LANG_ROOT="${SCRIPT_DIR}/../../../lang"
+EL_UI_ROOT="${SCRIPT_DIR}/../.."
+EL_RUNTIME="${EL_LANG_ROOT}/el-compiler/runtime"
+EL_NATIVE_VESSEL="${EL_UI_ROOT}/vessels/el-native/src/main.el"
+EL_APP_ENTRY="${EL_UI_ROOT}/examples/native-hello/src/main.el"
+EL_MANIFEST="${EL_UI_ROOT}/examples/native-hello/manifest.el"
+SOURCES_DIR="${SCRIPT_DIR}/NativeHello"
+BUILD_DIR="${SCRIPT_DIR}/build"
+
+# Locate elc.
+if command -v elc &>/dev/null; then
+ ELC="elc"
+elif [ -x "${EL_LANG_ROOT}/dist/platform/elc" ]; then
+ ELC="${EL_LANG_ROOT}/dist/platform/elc"
+else
+ echo "Error: elc not found. Add it to PATH or place it at:"
+ echo " ${EL_LANG_ROOT}/dist/platform/elc"
+ exit 1
+fi
+
+gen() {
+ echo "==> Generating native_hello.c..."
+ EL_MANIFEST="${EL_MANIFEST}" \
+ "${ELC}" "${EL_APP_ENTRY}" \
+ > "${SOURCES_DIR}/native_hello.c"
+ # Rename the compiled el main() to el_app_main so it doesn't conflict with
+ # main.m's main(). main.m's main() is the real entry point for UIApplicationMain.
+ # Use a simple sed substitution on the exact signature elc emits.
+ sed -i '' 's/^int main(int _argc, char\*\* _argv) {/int el_app_main(int _argc, char** _argv) {/' \
+ "${SOURCES_DIR}/native_hello.c"
+
+ echo "==> Generating el_native_vessel.c..."
+ "${ELC}" "${EL_NATIVE_VESSEL}" \
+ > "${SOURCES_DIR}/el_native_vessel.c"
+ # Rename vessel main() similarly.
+ sed -i '' 's/^int main(int _argc, char\*\* _argv) {/int el_vessel_main(int _argc, char** _argv) {/' \
+ "${SOURCES_DIR}/el_native_vessel.c"
+
+ echo "==> Copying runtime files..."
+ cp "${EL_RUNTIME}/el_uikit.m" "${SOURCES_DIR}/"
+ cp "${EL_RUNTIME}/el_seed.c" "${SOURCES_DIR}/"
+ cp "${EL_RUNTIME}/el_runtime.c" "${SOURCES_DIR}/"
+ cp "${EL_RUNTIME}/el_runtime.h" "${SOURCES_DIR}/"
+ cp "${EL_RUNTIME}/el_native_target.h" "${SOURCES_DIR}/"
+
+ echo "==> Source generation complete."
+}
+
+xcode_build() {
+ echo "==> Building with xcodebuild (iphonesimulator)..."
+ xcodebuild \
+ -project "${SCRIPT_DIR}/NativeHello.xcodeproj" \
+ -scheme NativeHello \
+ -sdk iphonesimulator \
+ -configuration Debug \
+ -derivedDataPath "${BUILD_DIR}" \
+ CLANG_ENABLE_OBJC_ARC=NO \
+ OTHER_CFLAGS="-DEL_TARGET_IOS" \
+ OTHER_LDFLAGS="-ldl" \
+ build
+
+ APP_PATH=$(find "${BUILD_DIR}" -name "NativeHello.app" -maxdepth 6 | head -1)
+ if [ -n "${APP_PATH}" ]; then
+ echo "==> Build complete."
+ echo " App bundle: ${APP_PATH}"
+ else
+ echo "==> Build complete (app bundle in ${BUILD_DIR})."
+ fi
+}
+
+install_and_launch() {
+ gen
+ xcode_build
+
+ APP_PATH=$(find "${BUILD_DIR}" -name "NativeHello.app" -maxdepth 6 | head -1)
+ if [ -z "${APP_PATH}" ]; then
+ echo "Error: could not find NativeHello.app in ${BUILD_DIR}"
+ exit 1
+ fi
+
+ # Boot the default simulator if not already booted.
+ SIM_UDID=$(xcrun simctl list devices available --json \
+ | python3 -c "
+import json,sys
+d=json.load(sys.stdin)['devices']
+for runtime,devs in d.items():
+ for dev in devs:
+ if 'iPhone' in dev['name'] and dev['isAvailable']:
+ print(dev['udid'])
+ exit()
+")
+ if [ -z "${SIM_UDID}" ]; then
+ echo "Error: no available iPhone simulator found."
+ exit 1
+ fi
+ echo "==> Using simulator: ${SIM_UDID}"
+ xcrun simctl boot "${SIM_UDID}" 2>/dev/null || true
+ open -a Simulator
+
+ echo "==> Installing app..."
+ xcrun simctl install "${SIM_UDID}" "${APP_PATH}"
+
+ echo "==> Launching app..."
+ xcrun simctl launch "${SIM_UDID}" "ai.neurontechnologies.el.nativehello"
+}
+
+clean() {
+ echo "==> Cleaning..."
+ rm -rf "${BUILD_DIR}"
+ echo "==> Clean complete."
+}
+
+case "${1:-all}" in
+ gen) gen ;;
+ build) xcode_build ;;
+ install) install_and_launch ;;
+ clean) clean ;;
+ all|*) gen && xcode_build ;;
+esac
diff --git a/ui/examples/native-hello/-o b/ui/examples/native-hello/-o
new file mode 100644
index 0000000..e69de29
diff --git a/ui/examples/native-hello/Dockerfile.linux-gtk4 b/ui/examples/native-hello/Dockerfile.linux-gtk4
new file mode 100644
index 0000000..2079162
--- /dev/null
+++ b/ui/examples/native-hello/Dockerfile.linux-gtk4
@@ -0,0 +1,88 @@
+FROM ubuntu:22.04
+
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ libgtk-4-dev \
+ pkg-config \
+ libcurl4-openssl-dev \
+ binutils \
+ file \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+# All sources arrive in /build — runtime/ subdirectory contains the el runtime,
+# top-level contains elc-generated app/vessel C files.
+COPY runtime/ runtime/
+COPY el_native_vessel.c el_native_vessel.h native_hello.c ./
+
+# Compile el_gtk4.c
+RUN gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) \
+ -std=c11 -I/build/runtime \
+ -c /build/runtime/el_gtk4.c -o /build/el_gtk4.o && \
+ echo "el_gtk4.c compiled OK"
+
+# Compile el_seed.c
+RUN gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) \
+ -std=c11 -I/build/runtime \
+ -c /build/runtime/el_seed.c -o /build/el_seed.o && \
+ echo "el_seed.c compiled OK"
+
+# Compile el_runtime.c
+RUN gcc -std=c11 -I/build/runtime \
+ -c /build/runtime/el_runtime.c -o /build/el_runtime.o && \
+ echo "el_runtime.c compiled OK"
+
+# Patch el_runtime.o: hide __-prefixed symbols that el_seed.o also defines.
+# This is the Linux objcopy equivalent of macOS nmedit -s.
+# el_seed.o is canonical for all __ symbols; el_runtime.o provides high-level
+# builtins (str_len, json_get_string, etc.) that el_seed.o does NOT export.
+RUN nm /build/el_seed.o | awk '/[0-9a-f]+ T /{print $3}' | sort > /build/.seed_T.txt && \
+ nm /build/el_runtime.o | awk '/[0-9a-f]+ T /{print $3}' | sort > /build/.rt_T.txt && \
+ comm -12 /build/.seed_T.txt /build/.rt_T.txt > /build/.dups.txt && \
+ if [ -s /build/.dups.txt ]; then \
+ ARGS=$(awk '{printf "--localize-symbol=%s ", $1}' /build/.dups.txt); \
+ eval "objcopy $ARGS /build/el_runtime.o"; \
+ echo "Patched $(wc -l < /build/.dups.txt) duplicate symbols in el_runtime.o"; \
+ else \
+ echo "No duplicate symbols found"; \
+ fi
+
+# Compile vessel
+RUN gcc -DEL_TARGET_LINUX -std=c11 -I/build/runtime \
+ -include /build/runtime/el_native_target.h \
+ -c /build/el_native_vessel.c -o /build/el_native_vessel.o && \
+ echo "vessel compiled OK"
+
+# Compile app (before patching vessel, so we know which symbols to localise)
+RUN gcc -DEL_TARGET_LINUX -std=c11 -I/build/runtime \
+ -include /build/runtime/el_native_target.h \
+ -include /build/el_native_vessel.h \
+ -c /build/native_hello.c -o /build/native_hello.o && \
+ echo "app compiled OK"
+
+# Patch el_native_vessel.o:
+# 1. Localise main() so it doesn't conflict with the app's main.
+# 2. Localise any T/B symbols the app also defines (elc emits shared globals
+# like TOKEN_PRIMARY in every translation unit; GNU ld rejects duplicates
+# unlike macOS ld which picks the first one silently).
+RUN nm /build/native_hello.o | awk '/[0-9a-f]+ [TBb] /{print $3}' | sort > /build/.app_syms.txt && \
+ nm /build/el_native_vessel.o | awk '/[0-9a-f]+ [TBb] /{print $3}' | sort > /build/.vessel_syms.txt && \
+ comm -12 /build/.app_syms.txt /build/.vessel_syms.txt > /build/.vessel_dups.txt && \
+ echo "main" >> /build/.vessel_dups.txt && \
+ sort -u /build/.vessel_dups.txt -o /build/.vessel_dups.txt && \
+ ARGS=$(awk '{printf "--localize-symbol=%s ", $1}' /build/.vessel_dups.txt) && \
+ eval "objcopy $ARGS /build/el_native_vessel.o" && \
+ echo "Patched $(wc -l < /build/.vessel_dups.txt) symbols in el_native_vessel.o"
+
+# Link — add -lm for math functions (exp, log, etc.) used by el_runtime.c
+RUN gcc \
+ /build/native_hello.o \
+ /build/el_native_vessel.o \
+ /build/el_gtk4.o \
+ /build/el_runtime.o \
+ /build/el_seed.o \
+ $(pkg-config --libs gtk4) -ldl -lpthread -lcurl -lm \
+ -o /build/native-hello-linux && \
+ echo "==> Linked: native-hello-linux" && \
+ file /build/native-hello-linux
diff --git a/ui/examples/native-hello/Dockerfile.pi b/ui/examples/native-hello/Dockerfile.pi
new file mode 100644
index 0000000..a4a30a2
--- /dev/null
+++ b/ui/examples/native-hello/Dockerfile.pi
@@ -0,0 +1,99 @@
+FROM --platform=linux/arm64 ubuntu:22.04
+
+# GTK4 + SDL2 for Raspberry Pi (ARM64 / aarch64)
+# Targets Pi 3 / 4 / 5 running 64-bit Raspberry Pi OS or Ubuntu 22.04.
+#
+# GTK4 requires a desktop environment / Wayland compositor at runtime.
+# SDL2 works on bare framebuffer or KMS/DRM without a full desktop.
+#
+# Usage:
+# docker build --platform linux/arm64 \
+# -f Dockerfile.pi --tag el-native-pi-arm64 ./build-docker/
+# docker create --name pi-extract el-native-pi-arm64
+# docker cp pi-extract:/build/native-hello-linux-pi ./build/native-hello-pi-arm64
+# docker rm pi-extract
+#
+# The GTK4 build is the default. SDL2 is available as an alternative
+# for headless / bare-metal Pi deployments.
+
+RUN apt-get update && apt-get install -y \
+ build-essential \
+ libgtk-4-dev \
+ libsdl2-dev \
+ libsdl2-ttf-dev \
+ libsdl2-image-dev \
+ pkg-config \
+ libcurl4-openssl-dev \
+ binutils \
+ file \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /build
+
+# All sources arrive in /build
+COPY runtime/ runtime/
+COPY el_native_vessel.c el_native_vessel.h native_hello.c ./
+
+# ── GTK4 build ────────────────────────────────────────────────────────────────
+
+# Compile el_gtk4.c
+RUN gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) \
+ -std=c11 -I/build/runtime \
+ -c /build/runtime/el_gtk4.c -o /build/el_gtk4.o && \
+ echo "el_gtk4.c compiled OK"
+
+# Compile el_seed.c
+RUN gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) \
+ -std=c11 -I/build/runtime \
+ -c /build/runtime/el_seed.c -o /build/el_seed.o && \
+ echo "el_seed.c compiled OK"
+
+# Compile el_runtime.c
+RUN gcc -std=c11 -I/build/runtime \
+ -c /build/runtime/el_runtime.c -o /build/el_runtime.o && \
+ echo "el_runtime.c compiled OK"
+
+# Patch el_runtime.o: localise __-prefixed duplicate symbols (el_seed.o is canonical)
+RUN nm /build/el_seed.o | awk '/[0-9a-f]+ T /{print $3}' | sort > /build/.seed_T.txt && \
+ nm /build/el_runtime.o | awk '/[0-9a-f]+ T /{print $3}' | sort > /build/.rt_T.txt && \
+ comm -12 /build/.seed_T.txt /build/.rt_T.txt > /build/.dups.txt && \
+ if [ -s /build/.dups.txt ]; then \
+ ARGS=$(awk '{printf "--localize-symbol=%s ", $1}' /build/.dups.txt); \
+ eval "objcopy $ARGS /build/el_runtime.o"; \
+ echo "Patched $(wc -l < /build/.dups.txt) duplicate symbols in el_runtime.o"; \
+ fi
+
+# Compile vessel
+RUN gcc -DEL_TARGET_LINUX -std=c11 -I/build/runtime \
+ -include /build/runtime/el_native_target.h \
+ -c /build/el_native_vessel.c -o /build/el_native_vessel.o && \
+ echo "vessel compiled OK"
+
+# Compile app
+RUN gcc -DEL_TARGET_LINUX -std=c11 -I/build/runtime \
+ -include /build/runtime/el_native_target.h \
+ -include /build/el_native_vessel.h \
+ -c /build/native_hello.c -o /build/native_hello.o && \
+ echo "app compiled OK"
+
+# Patch el_native_vessel.o: localise shared globals (main + TOKEN_* etc.)
+RUN nm /build/native_hello.o | awk '/[0-9a-f]+ [TBb] /{print $3}' | sort > /build/.app_syms.txt && \
+ nm /build/el_native_vessel.o | awk '/[0-9a-f]+ [TBb] /{print $3}' | sort > /build/.vessel_syms.txt && \
+ comm -12 /build/.app_syms.txt /build/.vessel_syms.txt > /build/.vessel_dups.txt && \
+ echo "main" >> /build/.vessel_dups.txt && \
+ sort -u /build/.vessel_dups.txt -o /build/.vessel_dups.txt && \
+ ARGS=$(awk '{printf "--localize-symbol=%s ", $1}' /build/.vessel_dups.txt) && \
+ eval "objcopy $ARGS /build/el_native_vessel.o" && \
+ echo "Patched $(wc -l < /build/.vessel_dups.txt) symbols in el_native_vessel.o"
+
+# Link (GTK4)
+RUN gcc \
+ /build/native_hello.o \
+ /build/el_native_vessel.o \
+ /build/el_gtk4.o \
+ /build/el_runtime.o \
+ /build/el_seed.o \
+ $(pkg-config --libs gtk4) -ldl -lpthread -lcurl -lm \
+ -o /build/native-hello-linux-pi && \
+ echo "==> Linked: native-hello-linux-pi (GTK4)" && \
+ file /build/native-hello-linux-pi
diff --git a/ui/examples/native-hello/build-docker/el_native_vessel.c b/ui/examples/native-hello/build-docker/el_native_vessel.c
new file mode 100644
index 0000000..28bf38c
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/el_native_vessel.c
@@ -0,0 +1,459 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t native_init(void);
+el_val_t native_run_loop(void);
+el_val_t manifest_read(el_val_t path);
+el_val_t manifest_title(el_val_t m);
+el_val_t manifest_width(el_val_t m);
+el_val_t manifest_height(el_val_t m);
+el_val_t manifest_min_width(el_val_t m);
+el_val_t manifest_min_height(el_val_t m);
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height);
+el_val_t window_from_manifest(el_val_t manifest_path);
+el_val_t window_show(el_val_t handle);
+el_val_t window_set_title(el_val_t handle, el_val_t title);
+el_val_t vstack(el_val_t spacing);
+el_val_t vstack_tight(void);
+el_val_t hstack(el_val_t spacing);
+el_val_t zstack(void);
+el_val_t scroll(void);
+el_val_t label(el_val_t text);
+el_val_t button(el_val_t label);
+el_val_t text_field(el_val_t placeholder);
+el_val_t text_area(el_val_t placeholder);
+el_val_t image(el_val_t path_or_name);
+el_val_t widget_set_text(el_val_t handle, el_val_t text);
+el_val_t widget_get_text(el_val_t handle);
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold);
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left);
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p);
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py);
+el_val_t widget_set_width(el_val_t handle, el_val_t width);
+el_val_t widget_set_height(el_val_t handle, el_val_t height);
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex);
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius);
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled);
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden);
+el_val_t widget_add_child(el_val_t parent, el_val_t child);
+el_val_t widget_remove_child(el_val_t parent, el_val_t child);
+el_val_t widget_destroy(el_val_t handle);
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name);
+el_val_t hex_channel(el_val_t s, el_val_t offset);
+el_val_t hex_nibble(el_val_t c);
+el_val_t color_hex_r(el_val_t hex);
+el_val_t color_hex_g(el_val_t hex);
+el_val_t color_hex_b(el_val_t hex);
+el_val_t color_hex_a(el_val_t hex);
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex);
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex);
+el_val_t style_surface(el_val_t handle);
+el_val_t style_button_primary(el_val_t handle);
+el_val_t style_label_body(el_val_t handle);
+el_val_t style_label_heading(el_val_t handle);
+el_val_t style_label_muted(el_val_t handle);
+
+el_val_t TOKEN_PRIMARY;
+el_val_t TOKEN_ON_PRIMARY;
+el_val_t TOKEN_BACKGROUND;
+el_val_t TOKEN_ON_BG;
+el_val_t TOKEN_SURFACE;
+el_val_t TOKEN_ON_SURFACE;
+el_val_t TOKEN_OUTLINE;
+el_val_t TOKEN_ERROR;
+
+el_val_t native_init(void) {
+ __native_init();
+ return 0;
+}
+
+el_val_t native_run_loop(void) {
+ __native_run_loop();
+ return 0;
+}
+
+el_val_t manifest_read(el_val_t path) {
+ return __manifest_read(path);
+ return 0;
+}
+
+el_val_t manifest_title(el_val_t m) {
+ return json_get_string(m, EL_STR("title"));
+ return 0;
+}
+
+el_val_t manifest_width(el_val_t m) {
+ return json_get_int(m, EL_STR("width"));
+ return 0;
+}
+
+el_val_t manifest_height(el_val_t m) {
+ return json_get_int(m, EL_STR("height"));
+ return 0;
+}
+
+el_val_t manifest_min_width(el_val_t m) {
+ return json_get_int(m, EL_STR("min_width"));
+ return 0;
+}
+
+el_val_t manifest_min_height(el_val_t m) {
+ return json_get_int(m, EL_STR("min_height"));
+ return 0;
+}
+
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height) {
+ return __window_create(title, width, height, min_width, min_height);
+ return 0;
+}
+
+el_val_t window_from_manifest(el_val_t manifest_path) {
+ el_val_t m = manifest_read(manifest_path);
+ el_val_t title = manifest_title(m);
+ el_val_t w = manifest_width(m);
+ el_val_t h = manifest_height(m);
+ el_val_t mw = manifest_min_width(m);
+ el_val_t mh = manifest_min_height(m);
+ el_val_t safe_w = ({ el_val_t _if_result_1 = 0; if ((w > 0)) { _if_result_1 = (w); } else { _if_result_1 = (1200); } _if_result_1; });
+ el_val_t safe_h = ({ el_val_t _if_result_2 = 0; if ((h > 0)) { _if_result_2 = (h); } else { _if_result_2 = (800); } _if_result_2; });
+ el_val_t safe_mw = ({ el_val_t _if_result_3 = 0; if ((mw > 0)) { _if_result_3 = (mw); } else { _if_result_3 = (600); } _if_result_3; });
+ el_val_t safe_mh = ({ el_val_t _if_result_4 = 0; if ((mh > 0)) { _if_result_4 = (mh); } else { _if_result_4 = (400); } _if_result_4; });
+ el_val_t safe_t = ({ el_val_t _if_result_5 = 0; if ((str_len(title) > 0)) { _if_result_5 = (title); } else { _if_result_5 = (EL_STR("App")); } _if_result_5; });
+ return window_create(safe_t, safe_w, safe_h, safe_mw, safe_mh);
+ return 0;
+}
+
+el_val_t window_show(el_val_t handle) {
+ __window_show(handle);
+ return 0;
+}
+
+el_val_t window_set_title(el_val_t handle, el_val_t title) {
+ __window_set_title(handle, title);
+ return 0;
+}
+
+el_val_t vstack(el_val_t spacing) {
+ return __vstack_create(spacing);
+ return 0;
+}
+
+el_val_t vstack_tight(void) {
+ return __vstack_create(0);
+ return 0;
+}
+
+el_val_t hstack(el_val_t spacing) {
+ return __hstack_create(spacing);
+ return 0;
+}
+
+el_val_t zstack(void) {
+ return __zstack_create();
+ return 0;
+}
+
+el_val_t scroll(void) {
+ return __scroll_create();
+ return 0;
+}
+
+el_val_t label(el_val_t text) {
+ return __label_create(text);
+ return 0;
+}
+
+el_val_t button(el_val_t label) {
+ return __button_create(label);
+ return 0;
+}
+
+el_val_t text_field(el_val_t placeholder) {
+ return __text_field_create(placeholder);
+ return 0;
+}
+
+el_val_t text_area(el_val_t placeholder) {
+ return __text_area_create(placeholder);
+ return 0;
+}
+
+el_val_t image(el_val_t path_or_name) {
+ return __image_create(path_or_name);
+ return 0;
+}
+
+el_val_t widget_set_text(el_val_t handle, el_val_t text) {
+ __widget_set_text(handle, text);
+ return 0;
+}
+
+el_val_t widget_get_text(el_val_t handle) {
+ return __widget_get_text(handle);
+ return 0;
+}
+
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a) {
+ __widget_set_bg_color(handle, r, g, b, a);
+ return 0;
+}
+
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold) {
+ el_val_t bold_int = ({ el_val_t _if_result_6 = 0; if (bold) { _if_result_6 = (1); } else { _if_result_6 = (0); } _if_result_6; });
+ __widget_set_font(handle, family, size, bold_int);
+ return 0;
+}
+
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left) {
+ __widget_set_padding(handle, top, right, bottom, left);
+ return 0;
+}
+
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p) {
+ __widget_set_padding(handle, p, p, p, p);
+ return 0;
+}
+
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py) {
+ __widget_set_padding(handle, py, px, py, px);
+ return 0;
+}
+
+el_val_t widget_set_width(el_val_t handle, el_val_t width) {
+ __widget_set_width(handle, width);
+ return 0;
+}
+
+el_val_t widget_set_height(el_val_t handle, el_val_t height) {
+ __widget_set_height(handle, height);
+ return 0;
+}
+
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex) {
+ __widget_set_flex(handle, flex);
+ return 0;
+}
+
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius) {
+ __widget_set_corner_radius(handle, radius);
+ return 0;
+}
+
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled) {
+ el_val_t d = ({ el_val_t _if_result_7 = 0; if (disabled) { _if_result_7 = (1); } else { _if_result_7 = (0); } _if_result_7; });
+ __widget_set_disabled(handle, d);
+ return 0;
+}
+
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden) {
+ el_val_t h = ({ el_val_t _if_result_8 = 0; if (hidden) { _if_result_8 = (1); } else { _if_result_8 = (0); } _if_result_8; });
+ __widget_set_hidden(handle, h);
+ return 0;
+}
+
+el_val_t widget_add_child(el_val_t parent, el_val_t child) {
+ __widget_add_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_remove_child(el_val_t parent, el_val_t child) {
+ __widget_remove_child(parent, child);
+ return 0;
+}
+
+el_val_t widget_destroy(el_val_t handle) {
+ __widget_destroy(handle);
+ return 0;
+}
+
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name) {
+ __widget_on_click(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name) {
+ __widget_on_change(handle, fn_name);
+ return 0;
+}
+
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name) {
+ __widget_on_submit(handle, fn_name);
+ return 0;
+}
+
+el_val_t hex_channel(el_val_t s, el_val_t offset) {
+ el_val_t hi = str_slice(s, offset, (offset + 1));
+ el_val_t lo = str_slice(s, (offset + 1), (offset + 2));
+ el_val_t h = hex_nibble(hi);
+ el_val_t l = hex_nibble(lo);
+ return ((h * 16) + l);
+ return 0;
+}
+
+el_val_t hex_nibble(el_val_t c) {
+ if (str_eq(c, EL_STR("0"))) {
+ return 0;
+ }
+ if (str_eq(c, EL_STR("1"))) {
+ return 1;
+ }
+ if (str_eq(c, EL_STR("2"))) {
+ return 2;
+ }
+ if (str_eq(c, EL_STR("3"))) {
+ return 3;
+ }
+ if (str_eq(c, EL_STR("4"))) {
+ return 4;
+ }
+ if (str_eq(c, EL_STR("5"))) {
+ return 5;
+ }
+ if (str_eq(c, EL_STR("6"))) {
+ return 6;
+ }
+ if (str_eq(c, EL_STR("7"))) {
+ return 7;
+ }
+ if (str_eq(c, EL_STR("8"))) {
+ return 8;
+ }
+ if (str_eq(c, EL_STR("9"))) {
+ return 9;
+ }
+ if (str_eq(c, EL_STR("a"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("b"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("c"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("d"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("e"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("f"))) {
+ return 15;
+ }
+ if (str_eq(c, EL_STR("A"))) {
+ return 10;
+ }
+ if (str_eq(c, EL_STR("B"))) {
+ return 11;
+ }
+ if (str_eq(c, EL_STR("C"))) {
+ return 12;
+ }
+ if (str_eq(c, EL_STR("D"))) {
+ return 13;
+ }
+ if (str_eq(c, EL_STR("E"))) {
+ return 14;
+ }
+ if (str_eq(c, EL_STR("F"))) {
+ return 15;
+ }
+ return 0;
+ return 0;
+}
+
+el_val_t color_hex_r(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_9 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_9 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_9 = (hex); } _if_result_9; });
+ el_val_t v = hex_channel(s, 0);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_g(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_10 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_10 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_10 = (hex); } _if_result_10; });
+ el_val_t v = hex_channel(s, 2);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_b(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_11 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_11 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_11 = (hex); } _if_result_11; });
+ el_val_t v = hex_channel(s, 4);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t color_hex_a(el_val_t hex) {
+ el_val_t s = ({ el_val_t _if_result_12 = 0; if (str_starts_with(hex, EL_STR("#"))) { _if_result_12 = (str_slice(hex, 1, str_len(hex))); } else { _if_result_12 = (hex); } _if_result_12; });
+ if (str_len(s) < 8) {
+ return el_from_float(1.0);
+ }
+ el_val_t v = hex_channel(s, 6);
+ return float_div(int_to_float(v), el_from_float(255.0));
+ return 0;
+}
+
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex) {
+ widget_set_bg_color(handle, color_hex_r(hex), color_hex_g(hex), color_hex_b(hex), color_hex_a(hex));
+ return 0;
+}
+
+el_val_t style_surface(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_SURFACE);
+ widget_set_corner_radius(handle, 8);
+ return 0;
+}
+
+el_val_t style_button_primary(el_val_t handle) {
+ widget_set_bg_color_hex(handle, TOKEN_PRIMARY);
+ widget_set_color_hex(handle, TOKEN_ON_PRIMARY);
+ widget_set_corner_radius(handle, 8);
+ widget_set_padding_xy(handle, 16, 8);
+ return 0;
+}
+
+el_val_t style_label_body(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 14, 0);
+ return 0;
+}
+
+el_val_t style_label_heading(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_ON_BG);
+ widget_set_font(handle, EL_STR("system"), 20, 1);
+ return 0;
+}
+
+el_val_t style_label_muted(el_val_t handle) {
+ widget_set_color_hex(handle, TOKEN_OUTLINE);
+ widget_set_font(handle, EL_STR("system"), 12, 0);
+ return 0;
+}
+
+int main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ TOKEN_PRIMARY = EL_STR("#60a5fa");
+ TOKEN_ON_PRIMARY = EL_STR("#0f172a");
+ TOKEN_BACKGROUND = EL_STR("#0f172a");
+ TOKEN_ON_BG = EL_STR("#f8fafc");
+ TOKEN_SURFACE = EL_STR("#1e293b");
+ TOKEN_ON_SURFACE = EL_STR("#f8fafc");
+ TOKEN_OUTLINE = EL_STR("#475569");
+ TOKEN_ERROR = EL_STR("#f87171");
+ return 0;
+}
+
diff --git a/ui/examples/native-hello/build-docker/el_native_vessel.h b/ui/examples/native-hello/build-docker/el_native_vessel.h
new file mode 100644
index 0000000..b62c288
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/el_native_vessel.h
@@ -0,0 +1,69 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t native_init(void);
+el_val_t native_run_loop(void);
+el_val_t manifest_read(el_val_t path);
+el_val_t manifest_title(el_val_t m);
+el_val_t manifest_width(el_val_t m);
+el_val_t manifest_height(el_val_t m);
+el_val_t manifest_min_width(el_val_t m);
+el_val_t manifest_min_height(el_val_t m);
+el_val_t window_create(el_val_t title, el_val_t width, el_val_t height, el_val_t min_width, el_val_t min_height);
+el_val_t window_from_manifest(el_val_t manifest_path);
+el_val_t window_show(el_val_t handle);
+el_val_t window_set_title(el_val_t handle, el_val_t title);
+el_val_t vstack(el_val_t spacing);
+el_val_t vstack_tight(void);
+el_val_t hstack(el_val_t spacing);
+el_val_t zstack(void);
+el_val_t scroll(void);
+el_val_t label(el_val_t text);
+el_val_t button(el_val_t label);
+el_val_t text_field(el_val_t placeholder);
+el_val_t text_area(el_val_t placeholder);
+el_val_t image(el_val_t path_or_name);
+el_val_t widget_set_text(el_val_t handle, el_val_t text);
+el_val_t widget_get_text(el_val_t handle);
+el_val_t widget_set_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g, el_val_t b, el_val_t a);
+el_val_t widget_set_font(el_val_t handle, el_val_t family, el_val_t size, el_val_t bold);
+el_val_t widget_set_padding(el_val_t handle, el_val_t top, el_val_t right, el_val_t bottom, el_val_t left);
+el_val_t widget_set_padding_all(el_val_t handle, el_val_t p);
+el_val_t widget_set_padding_xy(el_val_t handle, el_val_t px, el_val_t py);
+el_val_t widget_set_width(el_val_t handle, el_val_t width);
+el_val_t widget_set_height(el_val_t handle, el_val_t height);
+el_val_t widget_set_flex(el_val_t handle, el_val_t flex);
+el_val_t widget_set_corner_radius(el_val_t handle, el_val_t radius);
+el_val_t widget_set_disabled(el_val_t handle, el_val_t disabled);
+el_val_t widget_set_hidden(el_val_t handle, el_val_t hidden);
+el_val_t widget_add_child(el_val_t parent, el_val_t child);
+el_val_t widget_remove_child(el_val_t parent, el_val_t child);
+el_val_t widget_destroy(el_val_t handle);
+el_val_t widget_on_click(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_change(el_val_t handle, el_val_t fn_name);
+el_val_t widget_on_submit(el_val_t handle, el_val_t fn_name);
+el_val_t hex_channel(el_val_t s, el_val_t offset);
+el_val_t hex_nibble(el_val_t c);
+el_val_t color_hex_r(el_val_t hex);
+el_val_t color_hex_g(el_val_t hex);
+el_val_t color_hex_b(el_val_t hex);
+el_val_t color_hex_a(el_val_t hex);
+el_val_t widget_set_color_hex(el_val_t handle, el_val_t hex);
+el_val_t widget_set_bg_color_hex(el_val_t handle, el_val_t hex);
+el_val_t style_surface(el_val_t handle);
+el_val_t style_button_primary(el_val_t handle);
+el_val_t style_label_body(el_val_t handle);
+el_val_t style_label_heading(el_val_t handle);
+el_val_t style_label_muted(el_val_t handle);
+
+el_val_t TOKEN_PRIMARY;
+el_val_t TOKEN_ON_PRIMARY;
+el_val_t TOKEN_BACKGROUND;
+el_val_t TOKEN_ON_BG;
+el_val_t TOKEN_SURFACE;
+el_val_t TOKEN_ON_SURFACE;
+el_val_t TOKEN_OUTLINE;
+el_val_t TOKEN_ERROR;
+
diff --git a/ui/examples/native-hello/build-docker/native_hello.c b/ui/examples/native-hello/build-docker/native_hello.c
new file mode 100644
index 0000000..050d56a
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/native_hello.c
@@ -0,0 +1,123 @@
+#include
+#include
+#include "el_runtime.h"
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data);
+el_val_t on_counter_click(el_val_t widget, el_val_t data);
+el_val_t on_name_change(el_val_t widget, el_val_t data);
+el_val_t app_build(el_val_t window);
+
+el_val_t g_window;
+el_val_t g_label;
+el_val_t g_input;
+el_val_t g_button;
+el_val_t g_counter;
+el_val_t g_counter_lbl;
+el_val_t manifest_env;
+el_val_t manifest_path;
+el_val_t win;
+el_val_t g_window;
+
+el_val_t on_greet_click(el_val_t widget, el_val_t data) {
+ el_val_t name = widget_get_text(g_input);
+ el_val_t greeting = ({ el_val_t _if_result_1 = 0; if ((str_len(name) > 0)) { _if_result_1 = (el_str_concat(el_str_concat(EL_STR("Hello, "), name), EL_STR("!"))); } else { _if_result_1 = (EL_STR("Hello, World!")); } _if_result_1; });
+ widget_set_text(g_label, greeting);
+ return 0;
+}
+
+el_val_t on_counter_click(el_val_t widget, el_val_t data) {
+ el_val_t g_counter = (g_counter + 1);
+ widget_set_text(g_counter_lbl, el_str_concat(EL_STR("Clicks: "), int_to_str(g_counter)));
+ return 0;
+}
+
+el_val_t on_name_change(el_val_t widget, el_val_t data) {
+ el_val_t has_text = (str_len(data) > 0);
+ widget_set_disabled(g_button, !has_text);
+ return 0;
+}
+
+el_val_t app_build(el_val_t window) {
+ el_val_t root = vstack(0);
+ widget_set_padding_all(root, 24);
+ widget_set_bg_color_hex(root, EL_STR("#0f172a"));
+ widget_set_flex(root, 1);
+ el_val_t title = label(EL_STR("el-native \xe2\x80\x94 native widget demo"));
+ style_label_heading(title);
+ widget_add_child(root, title);
+ el_val_t gap1 = label(EL_STR(""));
+ widget_set_height(gap1, 16);
+ widget_add_child(root, gap1);
+ el_val_t subtitle = label(EL_STR("AppKit controls from el code, no ObjC in the app layer."));
+ style_label_muted(subtitle);
+ widget_add_child(root, subtitle);
+ el_val_t gap2 = label(EL_STR(""));
+ widget_set_height(gap2, 24);
+ widget_add_child(root, gap2);
+ el_val_t input_row = hstack(8);
+ el_val_t name_label = label(EL_STR("Name:"));
+ style_label_body(name_label);
+ widget_set_width(name_label, 60);
+ el_val_t input = text_field(EL_STR("Enter your name\xe2\x80\xa6"));
+ style_label_body(input);
+ widget_set_flex(input, 1);
+ widget_on_change(input, EL_STR("on_name_change"));
+ el_val_t g_input = input;
+ el_val_t greet_btn = button(EL_STR("Greet"));
+ style_button_primary(greet_btn);
+ widget_set_disabled(greet_btn, 1);
+ widget_on_click(greet_btn, EL_STR("on_greet_click"));
+ el_val_t g_button = greet_btn;
+ widget_add_child(input_row, name_label);
+ widget_add_child(input_row, input);
+ widget_add_child(input_row, greet_btn);
+ widget_add_child(root, input_row);
+ el_val_t gap3 = label(EL_STR(""));
+ widget_set_height(gap3, 12);
+ widget_add_child(root, gap3);
+ el_val_t greeting = label(EL_STR("Waiting for name\xe2\x80\xa6"));
+ style_label_body(greeting);
+ widget_set_color_hex(greeting, EL_STR("#60a5fa"));
+ widget_set_font(greeting, EL_STR("system"), 16, 1);
+ el_val_t g_label = greeting;
+ widget_add_child(root, greeting);
+ el_val_t gap4 = label(EL_STR(""));
+ widget_set_height(gap4, 24);
+ widget_add_child(root, gap4);
+ el_val_t counter_row = hstack(12);
+ el_val_t counter_lbl = label(EL_STR("Clicks: 0"));
+ style_label_body(counter_lbl);
+ el_val_t g_counter_lbl = counter_lbl;
+ el_val_t counter_btn = button(EL_STR("Click me"));
+ style_button_primary(counter_btn);
+ widget_on_click(counter_btn, EL_STR("on_counter_click"));
+ widget_add_child(counter_row, counter_lbl);
+ widget_add_child(counter_row, counter_btn);
+ widget_add_child(root, counter_row);
+ widget_add_child(window, root);
+ return 0;
+}
+
+int main(int _argc, char** _argv) {
+ el_runtime_init_args(_argc, _argv);
+ g_window = (-1);
+ g_label = (-1);
+ g_input = (-1);
+ g_button = (-1);
+ g_counter = 0;
+ g_counter_lbl = (-1);
+ native_init();
+ manifest_env = env(EL_STR("EL_MANIFEST"));
+ manifest_path = ({ el_val_t _if_result_2 = 0; if ((str_len(manifest_env) > 0)) { _if_result_2 = (manifest_env); } else { _if_result_2 = (EL_STR("manifest.el")); } _if_result_2; });
+ win = window_from_manifest(manifest_path);
+ g_window = win;
+ if (win < 0) {
+ println(EL_STR("Error: failed to create window. Is EL_TARGET_MACOS defined?"));
+ exit_program(1);
+ }
+ app_build(win);
+ window_show(win);
+ native_run_loop();
+ return 0;
+}
+
diff --git a/ui/examples/native-hello/build-docker/runtime/el_gtk4.c b/ui/examples/native-hello/build-docker/runtime/el_gtk4.c
new file mode 100644
index 0000000..1ae01d0
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/runtime/el_gtk4.c
@@ -0,0 +1,1027 @@
+/*
+ * el_gtk4.c — GTK4 backend for the el native widget system (Linux).
+ *
+ * This file implements the Linux GTK4 widget layer that el_seed.c calls
+ * through to when EL_TARGET_LINUX is defined.
+ *
+ * Architecture:
+ * el program (el code)
+ * → __widget_* C builtins in el_seed.c
+ * → el_gtk4_* C-callable functions declared here
+ * → GtkWidget* / GtkWindow* via GTK4 C API
+ *
+ * Widget handles: every widget is assigned an int64_t slot index into
+ * _el_widgets[]. The el program holds these as opaque Int values.
+ * Slot 0 is never valid (reserved; invalid handle = -1 convention).
+ *
+ * Threading: GTK4 must run on the main thread. el_gtk4_sync_main() uses
+ * g_main_context_invoke_full() to dispatch from any thread, waiting for
+ * completion via a GMutex/GCond pair — the same role as dispatch_sync() in
+ * the AppKit bridge.
+ *
+ * Callback mechanism: on signal, the bridge calls
+ * dlsym(RTLD_DEFAULT, fn_name)(widget_handle, event_data_string)
+ * matching the el_appkit.m / __thread_create pattern in el_seed.c.
+ *
+ * Compile:
+ * gcc -DEL_TARGET_LINUX $(pkg-config --cflags gtk4) \
+ * el_gtk4.c -c -o el_gtk4.o
+ * Then link el_gtk4.o alongside el_seed.c with:
+ * $(pkg-config --libs gtk4) -ldl
+ *
+ * GTK4 version notes:
+ * • gtk_widget_get_style_context() is deprecated in GTK 4.10+.
+ * A #if GTK_MINOR_VERSION guard selects the correct CSS-provider path.
+ * • gtk_editable_get_text() / gtk_editable_set_text() are the unified
+ * GTK4 text API for GtkEntry and any GtkEditable.
+ */
+
+#ifdef EL_TARGET_LINUX
+
+/* _GNU_SOURCE: expose RTLD_DEFAULT (dlfcn.h) and strdup (string.h) on Linux. */
+#ifndef _GNU_SOURCE
+#define _GNU_SOURCE
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* G_APPLICATION_DEFAULT_FLAGS was added in GLib 2.74.
+ * Ubuntu 22.04 ships GLib 2.72 — use the older alias when running on it. */
+#ifndef G_APPLICATION_DEFAULT_FLAGS
+#define G_APPLICATION_DEFAULT_FLAGS G_APPLICATION_FLAGS_NONE
+#endif
+
+/* el_val_t must already be defined by el_runtime.h / el_seed.h. */
+#ifndef EL_VAL_T_DEFINED
+typedef int64_t el_val_t;
+#endif
+
+#ifndef EL_CSTR
+#define EL_CSTR(v) ((const char*)(uintptr_t)(v))
+#endif
+#ifndef EL_STR
+#define EL_STR(s) ((el_val_t)(uintptr_t)(s))
+#endif
+
+/* ── Widget table ─────────────────────────────────────────────────────────── */
+
+#define EL_GTK4_MAX_WIDGETS 4096
+
+typedef enum {
+ EL_WIDGET_FREE = 0,
+ EL_WIDGET_WINDOW = 1,
+ EL_WIDGET_VSTACK = 2,
+ EL_WIDGET_HSTACK = 3,
+ EL_WIDGET_ZSTACK = 4,
+ EL_WIDGET_SCROLL = 5,
+ EL_WIDGET_LABEL = 6,
+ EL_WIDGET_BUTTON = 7,
+ EL_WIDGET_TEXTFIELD = 8,
+ EL_WIDGET_TEXTAREA = 9,
+ EL_WIDGET_IMAGE = 10,
+ EL_WIDGET_DIVIDER = 11,
+ EL_WIDGET_SPACER = 12,
+} ElWidgetKind;
+
+typedef struct {
+ ElWidgetKind kind;
+ GtkWidget* widget; /* Strong reference (g_object_ref'd on alloc). */
+ char* cb_click; /* El function name for click/submit events. */
+ char* cb_change;/* El function name for value-change events. */
+} ElWidget;
+
+static ElWidget _el_widgets[EL_GTK4_MAX_WIDGETS];
+static int _el_widget_count = 0;
+
+/* Allocate a slot; returns slot index or -1 if full. Takes a strong ref. */
+static int64_t el_widget_alloc(ElWidgetKind kind, GtkWidget* w) {
+ for (int i = 1; i < EL_GTK4_MAX_WIDGETS; i++) {
+ if (_el_widgets[i].kind == EL_WIDGET_FREE) {
+ _el_widgets[i].kind = kind;
+ _el_widgets[i].widget = (GtkWidget*)g_object_ref(w);
+ _el_widgets[i].cb_click = NULL;
+ _el_widgets[i].cb_change = NULL;
+ if (i > _el_widget_count) _el_widget_count = i;
+ return (int64_t)i;
+ }
+ }
+ return -1;
+}
+
+/* Look up a slot. Returns NULL for invalid/free handle. */
+static ElWidget* el_widget_get(int64_t handle) {
+ if (handle <= 0 || handle >= EL_GTK4_MAX_WIDGETS) return NULL;
+ if (_el_widgets[handle].kind == EL_WIDGET_FREE) return NULL;
+ return &_el_widgets[handle];
+}
+
+/* Release a slot. Unrefs the widget; callers must remove from parent first. */
+static void el_widget_free(int64_t handle) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ if (w->widget) { g_object_unref(w->widget); w->widget = NULL; }
+ w->kind = EL_WIDGET_FREE;
+ free(w->cb_click); w->cb_click = NULL;
+ free(w->cb_change); w->cb_change = NULL;
+}
+
+/* Forward declaration — defined below in the GtkApplication globals section. */
+static int _el_app_running;
+
+/* ── CSS / styling helpers ────────────────────────────────────────────────── */
+
+/*
+ * apply_css — attach a CSS snippet to a single widget with APPLICATION priority.
+ * GTK 4.10 deprecated gtk_widget_get_style_context(); use gtk_widget_add_css_class()
+ * + a display-level provider for new GTK, or the style-context path for older GTK.
+ *
+ * We use the style-context path behind a version guard because it reliably
+ * scopes the CSS to just this widget, which is what the el property setters
+ * need (each call replaces/adds to the widget's own stylesheet).
+ */
+static void apply_css(GtkWidget* widget, const char* css_str) {
+ if (!widget || !css_str) return;
+ GtkCssProvider* p = gtk_css_provider_new();
+
+#if GTK_CHECK_VERSION(4, 12, 0)
+ /* GTK 4.12+: load_from_string takes a plain string (no length). */
+ gtk_css_provider_load_from_string(p, css_str);
+#else
+ gtk_css_provider_load_from_data(p, css_str, -1);
+#endif
+
+#if GTK_MINOR_VERSION >= 10
+ /* 4.10+: style context is deprecated; use display-scoped provider + css class.
+ * We add a unique widget name so the selector targets only this widget. */
+ static guint64 _css_id_counter = 0;
+ char name_buf[32];
+ snprintf(name_buf, sizeof(name_buf), "el-w-%" G_GUINT64_FORMAT, ++_css_id_counter);
+ gtk_widget_set_name(widget, name_buf);
+
+ /* Wrap the CSS to scope to this widget's name. */
+ char* scoped = NULL;
+ int n = asprintf(&scoped, "#%s { %s }", name_buf, css_str);
+ (void)n;
+ if (scoped) {
+ GtkCssProvider* sp = gtk_css_provider_new();
+#if GTK_CHECK_VERSION(4, 12, 0)
+ gtk_css_provider_load_from_string(sp, scoped);
+#else
+ gtk_css_provider_load_from_data(sp, scoped, -1);
+#endif
+ gtk_style_context_add_provider_for_display(
+ gtk_widget_get_display(widget),
+ GTK_STYLE_PROVIDER(sp),
+ GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
+ g_object_unref(sp);
+ free(scoped);
+ }
+ g_object_unref(p);
+#else
+ /* GTK < 4.10: style context is available and widget-scoped. */
+ G_GNUC_BEGIN_IGNORE_DEPRECATIONS
+ gtk_style_context_add_provider(
+ gtk_widget_get_style_context(widget),
+ GTK_STYLE_PROVIDER(p),
+ GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
+ G_GNUC_END_IGNORE_DEPRECATIONS
+ g_object_unref(p);
+#endif
+}
+
+/* ── Main-thread dispatch ────────────────────────────────────────────────── */
+
+/*
+ * Dispatch a function on the GTK main thread, blocking until it completes.
+ * If already on the main thread, calls the function directly.
+ *
+ * Use a GMainContext + GMutex/GCond pair so we can synchronously wait for
+ * the idle callback to execute, mirroring dispatch_sync(main_queue, ...).
+ */
+
+typedef struct {
+ void (*fn)(void*);
+ void* data;
+ GMutex mutex;
+ GCond cond;
+ int done;
+} ElSyncCall;
+
+static gboolean _el_sync_dispatch(gpointer user_data) {
+ ElSyncCall* call = (ElSyncCall*)user_data;
+ call->fn(call->data);
+ g_mutex_lock(&call->mutex);
+ call->done = 1;
+ g_cond_signal(&call->cond);
+ g_mutex_unlock(&call->mutex);
+ return G_SOURCE_REMOVE;
+}
+
+static void el_gtk4_sync_main(void (*fn)(void*), void* data) {
+ /*
+ * Pre-run phase (el boot sequence, before native_run_loop):
+ * The GMainContext is not yet iterating. We are on the main thread.
+ * Call directly — no dispatch needed.
+ *
+ * Post-run phase (callbacks from GTK signal handlers or other threads):
+ * Dispatch to the main context and wait for completion.
+ */
+ if (!_el_app_running) {
+ fn(data);
+ return;
+ }
+
+ /* Check if we're already on the thread running the default main context. */
+ GMainContext* main_ctx = g_main_context_default();
+ if (g_main_context_is_owner(main_ctx)) {
+ fn(data);
+ return;
+ }
+ ElSyncCall call;
+ call.fn = fn;
+ call.data = data;
+ call.done = 0;
+ g_mutex_init(&call.mutex);
+ g_cond_init(&call.cond);
+
+ g_main_context_invoke(main_ctx, _el_sync_dispatch, &call);
+
+ g_mutex_lock(&call.mutex);
+ while (!call.done) {
+ g_cond_wait(&call.cond, &call.mutex);
+ }
+ g_mutex_unlock(&call.mutex);
+ g_mutex_clear(&call.mutex);
+ g_cond_clear(&call.cond);
+}
+
+/* Convenience macro: declare a local struct, fill it, dispatch. */
+#define EL_SYNC(type, var, ...) \
+ type var = { __VA_ARGS__ }; \
+ el_gtk4_sync_main(_el_sync_##var, &var)
+
+/* ── El callback invocation ──────────────────────────────────────────────── */
+
+/*
+ * El callback functions have signature (per el_seed.c conventions):
+ * void fn_name(int64_t handle, int64_t data)
+ * where data is a (const char*) cast to int64_t — same as AppKit bridge.
+ */
+typedef void (*ElCb2)(int64_t handle, int64_t data);
+
+static void el_gtk4_invoke_cb(const char* fn_name, int64_t handle,
+ const char* data) {
+ if (!fn_name || !*fn_name) return;
+ void* sym = dlsym(RTLD_DEFAULT, fn_name);
+ if (!sym) return;
+ ElCb2 fn = (ElCb2)sym;
+ fn(handle, (int64_t)(uintptr_t)(data ? data : ""));
+}
+
+/* ── GtkApplication (global) ─────────────────────────────────────────────── */
+
+static GtkApplication* _el_app = NULL;
+static int _el_app_running = 0; /* set to 1 after activate fires */
+
+/* ── Signal callbacks ────────────────────────────────────────────────────── */
+
+/* Slot index stored as widget data so signal handlers can look it up. */
+#define EL_SLOT_KEY "el-slot"
+
+static void _el_on_button_clicked(GtkButton* btn, gpointer user_data) {
+ (void)btn;
+ int64_t slot = (int64_t)(intptr_t)user_data;
+ ElWidget* w = el_widget_get(slot);
+ if (w && w->cb_click) {
+ el_gtk4_invoke_cb(w->cb_click, slot, "");
+ }
+}
+
+static void _el_on_entry_changed(GtkEditable* editable, gpointer user_data) {
+ int64_t slot = (int64_t)(intptr_t)user_data;
+ ElWidget* w = el_widget_get(slot);
+ if (w && w->cb_change) {
+ const char* text = gtk_editable_get_text(editable);
+ el_gtk4_invoke_cb(w->cb_change, slot, text ? text : "");
+ }
+}
+
+static void _el_on_entry_activate(GtkEntry* entry, gpointer user_data) {
+ (void)entry;
+ int64_t slot = (int64_t)(intptr_t)user_data;
+ ElWidget* w = el_widget_get(slot);
+ if (w && w->cb_click) {
+ const char* text = gtk_editable_get_text(GTK_EDITABLE(entry));
+ el_gtk4_invoke_cb(w->cb_click, slot, text ? text : "");
+ }
+}
+
+/* GtkTextBuffer "changed" signal for textarea. */
+static void _el_on_textbuffer_changed(GtkTextBuffer* buf, gpointer user_data) {
+ int64_t slot = (int64_t)(intptr_t)user_data;
+ ElWidget* w = el_widget_get(slot);
+ if (!w || !w->cb_change) return;
+ GtkTextIter start, end;
+ gtk_text_buffer_get_bounds(buf, &start, &end);
+ char* text = gtk_text_buffer_get_text(buf, &start, &end, FALSE);
+ el_gtk4_invoke_cb(w->cb_change, slot, text ? text : "");
+ g_free(text);
+}
+
+/* ── GtkApplication activate callback ───────────────────────────────────── */
+
+static void _el_app_activate(GtkApplication* app, gpointer user_data) {
+ /*
+ * Register all pre-created windows with the application, and mark the
+ * app as running so subsequent calls use el_gtk4_sync_main dispatching.
+ *
+ * On macOS the GTK4 Quartz backend requires g_application_run() on the
+ * main thread. Windows created before run use gtk_window_new(); we
+ * register them here via gtk_application_add_window() so they participate
+ * in the application lifecycle (app quits when last window closes, etc.).
+ */
+ (void)user_data;
+ _el_app_running = 1;
+ for (int i = 1; i < EL_GTK4_MAX_WIDGETS; i++) {
+ if (_el_widgets[i].kind == EL_WIDGET_WINDOW && _el_widgets[i].widget) {
+ gtk_application_add_window(app, GTK_WINDOW(_el_widgets[i].widget));
+ }
+ }
+}
+
+/* ── Public C API ─────────────────────────────────────────────────────────── */
+
+/*
+ * el_gtk4_init — initialize GTK4 + GtkApplication. Idempotent.
+ * Must be called once before any other el_gtk4_* function.
+ */
+void el_gtk4_init(void) {
+ static int done = 0;
+ if (done) return;
+ done = 1;
+
+ _el_app = gtk_application_new("ai.neuralplatform.el", G_APPLICATION_DEFAULT_FLAGS);
+ g_signal_connect(_el_app, "activate", G_CALLBACK(_el_app_activate), NULL);
+}
+
+/*
+ * el_gtk4_run_loop — start the GTK main loop. Never returns.
+ * el code calls __native_run_loop() which dispatches here.
+ *
+ * On macOS the GTK4 Quartz backend requires g_application_run() to be called
+ * from the main thread. Since the el boot sequence (native_init → window_create
+ * → widget_build → native_run_loop) runs on the main thread, g_application_run
+ * is always called from the main thread here.
+ *
+ * Windows created before this call use gtk_window_new() (no GApplication
+ * needed); the activate handler registers them with the GtkApplication.
+ */
+void el_gtk4_run_loop(void) {
+ if (!_el_app) el_gtk4_init();
+ int status = g_application_run(G_APPLICATION(_el_app), 0, NULL);
+ (void)status;
+}
+
+/* ── Window ───────────────────────────────────────────────────────────────── */
+
+typedef struct {
+ const char* title;
+ int width, height, min_width, min_height;
+ int64_t result;
+} _ElWindowCreateArgs;
+
+static void _el_window_create_main(void* vp) {
+ _ElWindowCreateArgs* a = (_ElWindowCreateArgs*)vp;
+ /*
+ * Use gtk_window_new() rather than gtk_application_window_new().
+ * gtk_application_window_new() requires the GApplication to be running
+ * (i.e., after g_application_run / activate), which is not yet the case
+ * when the el boot sequence creates windows before native_run_loop().
+ * The activate handler in el_gtk4_run_loop registers these windows with
+ * the GtkApplication via gtk_application_add_window().
+ */
+ GtkWidget* win = gtk_window_new();
+ gtk_window_set_title(GTK_WINDOW(win), a->title ? a->title : "");
+ gtk_window_set_default_size(GTK_WINDOW(win), a->width, a->height);
+
+ /* Set minimum size via content-area size hint. */
+ if (a->min_width > 0 || a->min_height > 0) {
+ gtk_widget_set_size_request(win, a->min_width, a->min_height);
+ }
+
+ /* Use a vertical GtkBox as the root content widget so children stack
+ * vertically by default — mirrors the NSStackView root in AppKit. */
+ GtkWidget* root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
+ gtk_window_set_child(GTK_WINDOW(win), root);
+
+ a->result = el_widget_alloc(EL_WIDGET_WINDOW, win);
+}
+
+int64_t el_gtk4_window_create(const char* title, int width, int height,
+ int min_width, int min_height) {
+ _ElWindowCreateArgs a = { title, width, height, min_width, min_height, -1 };
+ el_gtk4_sync_main(_el_window_create_main, &a);
+ return a.result;
+}
+
+typedef struct { int64_t handle; } _ElHandleArgs;
+
+static void _el_window_show_main(void* vp) {
+ _ElHandleArgs* a = (_ElHandleArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind != EL_WIDGET_WINDOW) return;
+ gtk_window_present(GTK_WINDOW(w->widget));
+}
+
+void el_gtk4_window_show(int64_t handle) {
+ _ElHandleArgs a = { handle };
+ el_gtk4_sync_main(_el_window_show_main, &a);
+}
+
+typedef struct { int64_t handle; const char* title; } _ElSetTitleArgs;
+
+static void _el_window_set_title_main(void* vp) {
+ _ElSetTitleArgs* a = (_ElSetTitleArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind != EL_WIDGET_WINDOW) return;
+ gtk_window_set_title(GTK_WINDOW(w->widget), a->title ? a->title : "");
+}
+
+void el_gtk4_window_set_title(int64_t handle, const char* title) {
+ _ElSetTitleArgs a = { handle, title };
+ el_gtk4_sync_main(_el_window_set_title_main, &a);
+}
+
+/* ── Layout containers ───────────────────────────────────────────────────── */
+
+typedef struct {
+ GtkOrientation orient;
+ int spacing;
+ int64_t result;
+} _ElStackCreateArgs;
+
+static void _el_stack_create_main(void* vp) {
+ _ElStackCreateArgs* a = (_ElStackCreateArgs*)vp;
+ GtkWidget* box = gtk_box_new(a->orient, a->spacing);
+ ElWidgetKind kind = (a->orient == GTK_ORIENTATION_VERTICAL)
+ ? EL_WIDGET_VSTACK : EL_WIDGET_HSTACK;
+ a->result = el_widget_alloc(kind, box);
+}
+
+int64_t el_gtk4_vstack_create(int spacing) {
+ _ElStackCreateArgs a = { GTK_ORIENTATION_VERTICAL, spacing, -1 };
+ el_gtk4_sync_main(_el_stack_create_main, &a);
+ return a.result;
+}
+
+int64_t el_gtk4_hstack_create(int spacing) {
+ _ElStackCreateArgs a = { GTK_ORIENTATION_HORIZONTAL, spacing, -1 };
+ el_gtk4_sync_main(_el_stack_create_main, &a);
+ return a.result;
+}
+
+typedef struct { int64_t result; } _ElResultArgs;
+
+static void _el_zstack_create_main(void* vp) {
+ _ElResultArgs* a = (_ElResultArgs*)vp;
+ GtkWidget* overlay = gtk_overlay_new();
+ a->result = el_widget_alloc(EL_WIDGET_ZSTACK, overlay);
+}
+
+int64_t el_gtk4_zstack_create(void) {
+ _ElResultArgs a = { -1 };
+ el_gtk4_sync_main(_el_zstack_create_main, &a);
+ return a.result;
+}
+
+static void _el_scroll_create_main(void* vp) {
+ _ElResultArgs* a = (_ElResultArgs*)vp;
+ GtkWidget* sw = gtk_scrolled_window_new();
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sw),
+ GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
+ a->result = el_widget_alloc(EL_WIDGET_SCROLL, sw);
+}
+
+int64_t el_gtk4_scroll_create(void) {
+ _ElResultArgs a = { -1 };
+ el_gtk4_sync_main(_el_scroll_create_main, &a);
+ return a.result;
+}
+
+/* ── Widget factories ─────────────────────────────────────────────────────── */
+
+typedef struct { const char* text; int64_t result; } _ElTextArgs;
+
+static void _el_label_create_main(void* vp) {
+ _ElTextArgs* a = (_ElTextArgs*)vp;
+ GtkWidget* lbl = gtk_label_new(a->text ? a->text : "");
+ gtk_label_set_xalign(GTK_LABEL(lbl), 0.0f);
+ gtk_label_set_wrap(GTK_LABEL(lbl), FALSE);
+ a->result = el_widget_alloc(EL_WIDGET_LABEL, lbl);
+}
+
+int64_t el_gtk4_label_create(const char* text) {
+ _ElTextArgs a = { text, -1 };
+ el_gtk4_sync_main(_el_label_create_main, &a);
+ return a.result;
+}
+
+static void _el_button_create_main(void* vp) {
+ _ElTextArgs* a = (_ElTextArgs*)vp;
+ GtkWidget* btn = gtk_button_new_with_label(a->text ? a->text : "");
+ int64_t slot = el_widget_alloc(EL_WIDGET_BUTTON, btn);
+ if (slot >= 0) {
+ g_signal_connect(btn, "clicked",
+ G_CALLBACK(_el_on_button_clicked),
+ (gpointer)(intptr_t)slot);
+ }
+ a->result = slot;
+}
+
+int64_t el_gtk4_button_create(const char* label) {
+ _ElTextArgs a = { label, -1 };
+ el_gtk4_sync_main(_el_button_create_main, &a);
+ return a.result;
+}
+
+static void _el_text_field_create_main(void* vp) {
+ _ElTextArgs* a = (_ElTextArgs*)vp;
+ GtkWidget* entry = gtk_entry_new();
+ if (a->text && *a->text) {
+ gtk_entry_set_placeholder_text(GTK_ENTRY(entry), a->text);
+ }
+ int64_t slot = el_widget_alloc(EL_WIDGET_TEXTFIELD, entry);
+ if (slot >= 0) {
+ gpointer slot_ptr = (gpointer)(intptr_t)slot;
+ g_signal_connect(entry, "changed",
+ G_CALLBACK(_el_on_entry_changed), slot_ptr);
+ g_signal_connect(entry, "activate",
+ G_CALLBACK(_el_on_entry_activate), slot_ptr);
+ }
+ a->result = slot;
+}
+
+int64_t el_gtk4_text_field_create(const char* placeholder) {
+ _ElTextArgs a = { placeholder, -1 };
+ el_gtk4_sync_main(_el_text_field_create_main, &a);
+ return a.result;
+}
+
+static void _el_text_area_create_main(void* vp) {
+ _ElTextArgs* a = (_ElTextArgs*)vp;
+ GtkWidget* sw = gtk_scrolled_window_new();
+ gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sw),
+ GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
+ gtk_widget_set_vexpand(sw, TRUE);
+
+ GtkWidget* tv = gtk_text_view_new();
+ gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(tv), GTK_WRAP_WORD_CHAR);
+ gtk_widget_set_hexpand(tv, TRUE);
+ gtk_widget_set_vexpand(tv, TRUE);
+
+ /* GTK4 GtkTextView has no native placeholder; set accessible description. */
+ if (a->text && *a->text) {
+ gtk_accessible_update_property(GTK_ACCESSIBLE(tv),
+ GTK_ACCESSIBLE_PROPERTY_PLACEHOLDER, a->text, -1);
+ }
+
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(sw), tv);
+
+ /* Store the scroll window as the widget; text view accessible via child. */
+ int64_t slot = el_widget_alloc(EL_WIDGET_TEXTAREA, sw);
+ if (slot >= 0) {
+ GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(tv));
+ g_signal_connect(buf, "changed",
+ G_CALLBACK(_el_on_textbuffer_changed),
+ (gpointer)(intptr_t)slot);
+ }
+ a->result = slot;
+}
+
+int64_t el_gtk4_text_area_create(const char* placeholder) {
+ _ElTextArgs a = { placeholder, -1 };
+ el_gtk4_sync_main(_el_text_area_create_main, &a);
+ return a.result;
+}
+
+static void _el_image_create_main(void* vp) {
+ _ElTextArgs* a = (_ElTextArgs*)vp;
+ GtkWidget* img = NULL;
+ if (a->text && *a->text) {
+ /* Try as a filesystem path first. */
+ if (g_file_test(a->text, G_FILE_TEST_EXISTS)) {
+ img = gtk_picture_new_for_filename(a->text);
+ }
+ if (!img) {
+ /* Fall back to icon name. */
+ img = gtk_image_new_from_icon_name(a->text);
+ }
+ }
+ if (!img) {
+ img = gtk_picture_new();
+ }
+ a->result = el_widget_alloc(EL_WIDGET_IMAGE, img);
+}
+
+int64_t el_gtk4_image_create(const char* path_or_name) {
+ _ElTextArgs a = { path_or_name, -1 };
+ el_gtk4_sync_main(_el_image_create_main, &a);
+ return a.result;
+}
+
+/* ── Widget property setters ─────────────────────────────────────────────── */
+
+typedef struct { int64_t handle; const char* text; } _ElWidgetTextArgs;
+
+static void _el_widget_set_text_main(void* vp) {
+ _ElWidgetTextArgs* a = (_ElWidgetTextArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ const char* s = a->text ? a->text : "";
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ gtk_label_set_text(GTK_LABEL(w->widget), s);
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ gtk_editable_set_text(GTK_EDITABLE(w->widget), s);
+ break;
+ case EL_WIDGET_BUTTON:
+ gtk_button_set_label(GTK_BUTTON(w->widget), s);
+ break;
+ case EL_WIDGET_TEXTAREA: {
+ GtkWidget* tv = gtk_scrolled_window_get_child(
+ GTK_SCROLLED_WINDOW(w->widget));
+ if (tv && GTK_IS_TEXT_VIEW(tv)) {
+ GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(tv));
+ gtk_text_buffer_set_text(buf, s, -1);
+ }
+ break;
+ }
+ case EL_WIDGET_WINDOW:
+ gtk_window_set_title(GTK_WINDOW(w->widget), s);
+ break;
+ default: break;
+ }
+}
+
+void el_gtk4_widget_set_text(int64_t handle, const char* text) {
+ _ElWidgetTextArgs a = { handle, text };
+ el_gtk4_sync_main(_el_widget_set_text_main, &a);
+}
+
+typedef struct { int64_t handle; const char* result; } _ElWidgetGetTextArgs;
+
+static void _el_widget_get_text_main(void* vp) {
+ _ElWidgetGetTextArgs* a = (_ElWidgetGetTextArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) { a->result = ""; return; }
+ switch (w->kind) {
+ case EL_WIDGET_LABEL:
+ a->result = gtk_label_get_text(GTK_LABEL(w->widget));
+ break;
+ case EL_WIDGET_TEXTFIELD:
+ a->result = gtk_editable_get_text(GTK_EDITABLE(w->widget));
+ break;
+ case EL_WIDGET_BUTTON:
+ a->result = gtk_button_get_label(GTK_BUTTON(w->widget));
+ break;
+ case EL_WIDGET_TEXTAREA: {
+ GtkWidget* tv = gtk_scrolled_window_get_child(
+ GTK_SCROLLED_WINDOW(w->widget));
+ if (tv && GTK_IS_TEXT_VIEW(tv)) {
+ GtkTextBuffer* buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(tv));
+ GtkTextIter start, end;
+ gtk_text_buffer_get_bounds(buf, &start, &end);
+ /* Returns g_malloc'd string — caller treats it as short-lived. */
+ a->result = gtk_text_buffer_get_text(buf, &start, &end, FALSE);
+ } else {
+ a->result = "";
+ }
+ break;
+ }
+ default:
+ a->result = "";
+ break;
+ }
+}
+
+const char* el_gtk4_widget_get_text(int64_t handle) {
+ _ElWidgetGetTextArgs a = { handle, "" };
+ el_gtk4_sync_main(_el_widget_get_text_main, &a);
+ return a.result ? strdup(a.result) : strdup("");
+}
+
+typedef struct {
+ int64_t handle;
+ float r, g, b, a;
+} _ElColorArgs;
+
+static void _el_widget_set_color_main(void* vp) {
+ _ElColorArgs* a = (_ElColorArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ char css[128];
+ snprintf(css, sizeof(css), "color: rgba(%d,%d,%d,%.3f);",
+ (int)(a->r * 255.0f), (int)(a->g * 255.0f),
+ (int)(a->b * 255.0f), (double)a->a);
+ apply_css(w->widget, css);
+}
+
+void el_gtk4_widget_set_color(int64_t handle, float r, float g, float b, float a) {
+ _ElColorArgs args = { handle, r, g, b, a };
+ el_gtk4_sync_main(_el_widget_set_color_main, &args);
+}
+
+static void _el_widget_set_bg_color_main(void* vp) {
+ _ElColorArgs* a = (_ElColorArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ char css[128];
+ snprintf(css, sizeof(css), "background-color: rgba(%d,%d,%d,%.3f);",
+ (int)(a->r * 255.0f), (int)(a->g * 255.0f),
+ (int)(a->b * 255.0f), (double)a->a);
+ apply_css(w->widget, css);
+}
+
+void el_gtk4_widget_set_bg_color(int64_t handle, float r, float g, float b, float a) {
+ _ElColorArgs args = { handle, r, g, b, a };
+ el_gtk4_sync_main(_el_widget_set_bg_color_main, &args);
+}
+
+typedef struct {
+ int64_t handle;
+ const char* family;
+ int size;
+ int bold;
+} _ElFontArgs;
+
+static void _el_widget_set_font_main(void* vp) {
+ _ElFontArgs* a = (_ElFontArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ char css[256];
+ const char* fam = (a->family && *a->family && strcmp(a->family, "system") != 0)
+ ? a->family : "sans-serif";
+ snprintf(css, sizeof(css),
+ "font-family: %s; font-size: %dpx; font-weight: %s;",
+ fam, a->size, a->bold ? "bold" : "normal");
+ apply_css(w->widget, css);
+}
+
+void el_gtk4_widget_set_font(int64_t handle, const char* family, int size, int bold) {
+ _ElFontArgs a = { handle, family, size, bold };
+ el_gtk4_sync_main(_el_widget_set_font_main, &a);
+}
+
+typedef struct { int64_t handle; int top, right, bottom, left; } _ElPaddingArgs;
+
+static void _el_widget_set_padding_main(void* vp) {
+ _ElPaddingArgs* a = (_ElPaddingArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ char css[128];
+ snprintf(css, sizeof(css), "padding: %dpx %dpx %dpx %dpx;",
+ a->top, a->right, a->bottom, a->left);
+ apply_css(w->widget, css);
+}
+
+void el_gtk4_widget_set_padding(int64_t handle, int top, int right, int bottom, int left) {
+ _ElPaddingArgs a = { handle, top, right, bottom, left };
+ el_gtk4_sync_main(_el_widget_set_padding_main, &a);
+}
+
+typedef struct { int64_t handle; int value; } _ElIntPropArgs;
+
+static void _el_widget_set_width_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ gint cur_h = -1;
+ gtk_widget_get_size_request(w->widget, NULL, &cur_h);
+ gtk_widget_set_size_request(w->widget, a->value, cur_h);
+}
+
+void el_gtk4_widget_set_width(int64_t handle, int width) {
+ _ElIntPropArgs a = { handle, width };
+ el_gtk4_sync_main(_el_widget_set_width_main, &a);
+}
+
+static void _el_widget_set_height_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ gint cur_w = -1;
+ gtk_widget_get_size_request(w->widget, &cur_w, NULL);
+ gtk_widget_set_size_request(w->widget, cur_w, a->value);
+}
+
+void el_gtk4_widget_set_height(int64_t handle, int height) {
+ _ElIntPropArgs a = { handle, height };
+ el_gtk4_sync_main(_el_widget_set_height_main, &a);
+}
+
+static void _el_widget_set_flex_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ gboolean expand = (a->value > 0) ? TRUE : FALSE;
+ gtk_widget_set_hexpand(w->widget, expand);
+ gtk_widget_set_vexpand(w->widget, expand);
+}
+
+void el_gtk4_widget_set_flex(int64_t handle, int flex) {
+ _ElIntPropArgs a = { handle, flex };
+ el_gtk4_sync_main(_el_widget_set_flex_main, &a);
+}
+
+static void _el_widget_set_corner_radius_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ char css[64];
+ snprintf(css, sizeof(css), "border-radius: %dpx;", a->value);
+ apply_css(w->widget, css);
+}
+
+void el_gtk4_widget_set_corner_radius(int64_t handle, int radius) {
+ _ElIntPropArgs a = { handle, radius };
+ el_gtk4_sync_main(_el_widget_set_corner_radius_main, &a);
+}
+
+static void _el_widget_set_disabled_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ gtk_widget_set_sensitive(w->widget, !a->value);
+}
+
+void el_gtk4_widget_set_disabled(int64_t handle, int disabled) {
+ _ElIntPropArgs a = { handle, disabled };
+ el_gtk4_sync_main(_el_widget_set_disabled_main, &a);
+}
+
+static void _el_widget_set_hidden_main(void* vp) {
+ _ElIntPropArgs* a = (_ElIntPropArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w || w->kind == EL_WIDGET_WINDOW) return;
+ gtk_widget_set_visible(w->widget, !a->value);
+}
+
+void el_gtk4_widget_set_hidden(int64_t handle, int hidden) {
+ _ElIntPropArgs a = { handle, hidden };
+ el_gtk4_sync_main(_el_widget_set_hidden_main, &a);
+}
+
+/* ── Child management ─────────────────────────────────────────────────────── */
+
+typedef struct { int64_t parent; int64_t child; } _ElParentChildArgs;
+
+static void _el_widget_add_child_main(void* vp) {
+ _ElParentChildArgs* a = (_ElParentChildArgs*)vp;
+ ElWidget* pw = el_widget_get(a->parent);
+ ElWidget* cw = el_widget_get(a->child);
+ if (!pw || !cw) return;
+ if (cw->kind == EL_WIDGET_WINDOW) return; /* cannot add a window as child */
+
+ GtkWidget* child_widget = cw->widget;
+
+ switch (pw->kind) {
+ case EL_WIDGET_WINDOW: {
+ /* Append to the root VBox that window_create installs as child. */
+ GtkWidget* root = gtk_window_get_child(GTK_WINDOW(pw->widget));
+ if (root && GTK_IS_BOX(root)) {
+ gtk_box_append(GTK_BOX(root), child_widget);
+ } else {
+ /* Fallback: replace root child. */
+ gtk_window_set_child(GTK_WINDOW(pw->widget), child_widget);
+ }
+ break;
+ }
+ case EL_WIDGET_VSTACK:
+ case EL_WIDGET_HSTACK:
+ gtk_box_append(GTK_BOX(pw->widget), child_widget);
+ break;
+ case EL_WIDGET_ZSTACK:
+ /* First child becomes the base; subsequent children are overlays. */
+ if (!gtk_overlay_get_child(GTK_OVERLAY(pw->widget))) {
+ gtk_overlay_set_child(GTK_OVERLAY(pw->widget), child_widget);
+ } else {
+ gtk_overlay_add_overlay(GTK_OVERLAY(pw->widget), child_widget);
+ }
+ break;
+ case EL_WIDGET_SCROLL:
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(pw->widget),
+ child_widget);
+ break;
+ default:
+ /* Generic fallback — shouldn't be reached for standard widget kinds. */
+ break;
+ }
+}
+
+void el_gtk4_widget_add_child(int64_t parent, int64_t child) {
+ _ElParentChildArgs a = { parent, child };
+ el_gtk4_sync_main(_el_widget_add_child_main, &a);
+}
+
+static void _el_widget_remove_child_main(void* vp) {
+ _ElParentChildArgs* a = (_ElParentChildArgs*)vp;
+ ElWidget* pw = el_widget_get(a->parent);
+ ElWidget* cw = el_widget_get(a->child);
+ if (!pw || !cw) return;
+ if (cw->kind == EL_WIDGET_WINDOW) return;
+
+ GtkWidget* child_widget = cw->widget;
+
+ switch (pw->kind) {
+ case EL_WIDGET_WINDOW: {
+ GtkWidget* root = gtk_window_get_child(GTK_WINDOW(pw->widget));
+ if (root && GTK_IS_BOX(root)) {
+ gtk_box_remove(GTK_BOX(root), child_widget);
+ }
+ break;
+ }
+ case EL_WIDGET_VSTACK:
+ case EL_WIDGET_HSTACK:
+ gtk_box_remove(GTK_BOX(pw->widget), child_widget);
+ break;
+ case EL_WIDGET_ZSTACK:
+ gtk_overlay_remove_overlay(GTK_OVERLAY(pw->widget), child_widget);
+ break;
+ case EL_WIDGET_SCROLL:
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(pw->widget), NULL);
+ break;
+ default:
+ break;
+ }
+}
+
+void el_gtk4_widget_remove_child(int64_t parent, int64_t child) {
+ _ElParentChildArgs a = { parent, child };
+ el_gtk4_sync_main(_el_widget_remove_child_main, &a);
+}
+
+/* ── Event registration ───────────────────────────────────────────────────── */
+
+/*
+ * on_click / on_change / on_submit just store the function name string.
+ * The actual g_signal_connect calls happen at widget creation time.
+ * Here we update the stored callback so the already-connected signal handler
+ * will call the new function name.
+ */
+
+void el_gtk4_widget_on_click(int64_t handle, const char* fn_name) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_click);
+ w->cb_click = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+}
+
+void el_gtk4_widget_on_change(int64_t handle, const char* fn_name) {
+ ElWidget* w = el_widget_get(handle);
+ if (!w) return;
+ free(w->cb_change);
+ w->cb_change = (fn_name && *fn_name) ? strdup(fn_name) : NULL;
+}
+
+void el_gtk4_widget_on_submit(int64_t handle, const char* fn_name) {
+ /* For text fields, "submit" = Enter key activate → stored in cb_click. */
+ el_gtk4_widget_on_click(handle, fn_name);
+}
+
+/* ── Widget destroy ───────────────────────────────────────────────────────── */
+
+static void _el_widget_destroy_main(void* vp) {
+ _ElHandleArgs* a = (_ElHandleArgs*)vp;
+ ElWidget* w = el_widget_get(a->handle);
+ if (!w) return;
+ if (w->kind == EL_WIDGET_WINDOW) {
+ gtk_window_destroy(GTK_WINDOW(w->widget));
+ } else {
+ /* Unparent the widget before freeing the slot. */
+ GtkWidget* parent = gtk_widget_get_parent(w->widget);
+ if (parent) {
+ if (GTK_IS_BOX(parent)) {
+ gtk_box_remove(GTK_BOX(parent), w->widget);
+ } else if (GTK_IS_OVERLAY(parent)) {
+ gtk_overlay_remove_overlay(GTK_OVERLAY(parent), w->widget);
+ } else if (GTK_IS_SCROLLED_WINDOW(parent)) {
+ gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(parent), NULL);
+ }
+ /* gtk_window_get_child case: the parent is a GtkBox inside a window */
+ }
+ }
+ el_widget_free(a->handle);
+}
+
+void el_gtk4_widget_destroy(int64_t handle) {
+ _ElHandleArgs a = { handle };
+ el_gtk4_sync_main(_el_widget_destroy_main, &a);
+}
+
+#endif /* EL_TARGET_LINUX */
diff --git a/ui/examples/native-hello/build-docker/runtime/el_native_target.h b/ui/examples/native-hello/build-docker/runtime/el_native_target.h
new file mode 100644
index 0000000..dc1edcb
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/runtime/el_native_target.h
@@ -0,0 +1,573 @@
+/*
+ * el_native_target.h — Native widget declarations for el programs targeting
+ * native desktop UI (AppKit / GTK4 / Win32).
+ *
+ * This header is designed to be included AFTER el_runtime.h without conflict:
+ * - It does NOT redefine el_to_float, el_from_float, or any el_runtime.h
+ * static inlines.
+ * - It does NOT redeclare __println, __print, or other functions whose
+ * return types differ between el_seed.h and el_runtime.h.
+ * - It adds: native widget builtins + float arithmetic helpers that the
+ * current el_runtime.h omits but elc still emits calls to.
+ *
+ * Usage:
+ * Inject via -include at compile time, OR #include it after el_runtime.h.
+ *
+ * clang -DEL_TARGET_MACOS -include el_native_target.h -c my_app.c ...
+ */
+
+#pragma once
+
+#include
+#include
+
+/* el_val_t must already be defined by el_runtime.h or el_seed.h. */
+#ifndef EL_VAL_T_DEFINED
+typedef int64_t el_val_t;
+#endif
+
+/* ── Float arithmetic helpers ───────────────────────────────────────────────
+ * elc emits calls to float_div / float_mul etc. for Float-typed expressions.
+ * These were in el_runtime.c through v1.0.0-20260501 but are missing from the
+ * current el_runtime.h. Redeclared here as static inline to avoid link deps.
+ * Only defined if not already declared (old runtimes that still have them). */
+
+#ifndef EL_FLOAT_OPS_DEFINED
+#define EL_FLOAT_OPS_DEFINED
+
+/* el_to_float / el_from_float — bit-cast between el_val_t and double.
+ * Defined as static inline in both el_runtime.h and el_seed.h; we do NOT
+ * redefine them here. We rely on one of those headers being included first. */
+
+static inline el_val_t float_div(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b;
+ ur.d = (ub.d != 0.0) ? (ua.d / ub.d) : 0.0;
+ return ur.i;
+}
+
+static inline el_val_t float_mul(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d * ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_add(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d + ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_sub(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub, ur;
+ ua.i = a; ub.i = b; ur.d = ua.d - ub.d;
+ return ur.i;
+}
+
+static inline el_val_t float_lt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d < ub.d);
+}
+
+static inline el_val_t float_gt(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d > ub.d);
+}
+
+static inline el_val_t float_lte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d <= ub.d);
+}
+
+static inline el_val_t float_gte(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d >= ub.d);
+}
+
+static inline el_val_t float_eq(el_val_t a, el_val_t b) {
+ union { double d; int64_t i; } ua, ub;
+ ua.i = a; ub.i = b;
+ return (el_val_t)(ua.d == ub.d);
+}
+
+#endif /* EL_FLOAT_OPS_DEFINED */
+
+/* ── Native widget system (macOS AppKit) ────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_MACOS and linked with el_appkit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid. */
+
+#ifdef EL_TARGET_MACOS
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_MACOS */
+
+/* ── Native widget system (Linux GTK4) ──────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_LINUX and linked with el_gtk4.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * All functions have the same signatures as EL_TARGET_MACOS above. */
+
+#ifdef EL_TARGET_LINUX
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as EL_TARGET_MACOS */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LINUX */
+
+/* ── Native widget system (Windows Win32) ───────────────────────────────────
+ * Available when compiled with -DEL_TARGET_WIN32 and linked with el_win32.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ * Link: el_win32.obj comctl32.lib user32.lib gdi32.lib */
+
+#ifdef EL_TARGET_WIN32
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_WIN32 */
+
+/* ── Native widget system (iOS UIKit) ───────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_IOS and linked with el_uikit.m.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * iOS lifecycle note: UIApplicationMain never returns. The el program must
+ * store its UI-build logic in a void(*)(void) function pointer, assign it to
+ * el_main_entry_fn, then call __native_run_loop. ElAppDelegate invokes
+ * el_main_entry_fn inside didFinishLaunchingWithOptions.
+ * Call el_uikit_set_args(argc, argv) from main() before __native_run_loop. */
+
+#ifdef EL_TARGET_IOS
+
+/* Lifecycle entry-function hook — set before calling __native_run_loop. */
+extern void (*el_main_entry_fn)(void);
+
+/* Forward argc/argv from main() to UIApplicationMain. */
+void el_uikit_set_args(int argc, char** argv);
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_IOS */
+
+/* ── Native widget system (Android JNI) ─────────────────────────────────────
+ * Available when compiled with -DEL_TARGET_ANDROID and linked with
+ * libelruntime.so (which includes el_android.c compiled by the NDK build).
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Java companion: ElBridge.java (package com.neuron.el) must be compiled into
+ * the APK. The Activity must call ElBridge.init(this) before any widget ops.
+ *
+ * Link flags (in Android.mk or CMakeLists.txt):
+ * -landroid -llog -ldl */
+
+#ifdef EL_TARGET_ANDROID
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void); /* no-op on Android */
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_ANDROID */
+
+/* ── Native widget system (LVGL v9 — embedded / microcontroller) ─────────────
+ * Available when compiled with -DEL_TARGET_LVGL and linked with el_lvgl.c
+ * and the LVGL library (lvgl.a or lvgl source tree).
+ *
+ * Target platforms: ESP32, STM32, industrial panels. Any system with 256KB+
+ * RAM and an LVGL-compatible display driver. No OS required.
+ *
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Bare-metal / no dynamic linker:
+ * Compile with -DEL_LVGL_NO_DLSYM and provide:
+ * el_val_t el_lvgl_dispatch(const char *fn, el_val_t a, el_val_t b);
+ *
+ * Compile:
+ * gcc -DEL_TARGET_LVGL -I./lvgl el_lvgl.c -c -o el_lvgl.o
+ * # Then link with lvgl.a. */
+
+#ifdef EL_TARGET_LVGL
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader — same JSON output as all other native targets */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_LVGL */
+
+/* ── Native widget system (SDL2 — embedded / Pi) ────────────────────────────
+ * Available when compiled with -DEL_TARGET_SDL2 and linked with el_sdl2.c.
+ * Widget handles are opaque int64_t slot indices; -1 = invalid.
+ *
+ * Target: Raspberry Pi Zero, embedded Linux, any system with a framebuffer
+ * and SDL2 available. No GTK, no desktop environment required.
+ *
+ * Compile:
+ * gcc -DEL_TARGET_SDL2 $(sdl2-config --cflags) -c el_sdl2.c -o el_sdl2.o
+ * Link:
+ * $(sdl2-config --libs) -lSDL2_ttf -lSDL2_image -ldl */
+
+#ifdef EL_TARGET_SDL2
+
+/* Initialisation */
+void __native_init(void);
+void __native_run_loop(void);
+
+/* Window */
+el_val_t __window_create(el_val_t title, el_val_t width, el_val_t height,
+ el_val_t min_width, el_val_t min_height);
+void __window_show(el_val_t handle);
+void __window_set_title(el_val_t handle, el_val_t title);
+
+/* Layout containers */
+el_val_t __vstack_create(el_val_t spacing);
+el_val_t __hstack_create(el_val_t spacing);
+el_val_t __zstack_create(void);
+el_val_t __scroll_create(void);
+
+/* Widgets */
+el_val_t __label_create(el_val_t text);
+el_val_t __button_create(el_val_t label);
+el_val_t __text_field_create(el_val_t placeholder);
+el_val_t __text_area_create(el_val_t placeholder);
+el_val_t __image_create(el_val_t path_or_name);
+
+/* Widget properties */
+void __widget_set_text(el_val_t handle, el_val_t text);
+el_val_t __widget_get_text(el_val_t handle);
+void __widget_set_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_bg_color(el_val_t handle, el_val_t r, el_val_t g,
+ el_val_t b, el_val_t a);
+void __widget_set_font(el_val_t handle, el_val_t family,
+ el_val_t size, el_val_t bold);
+void __widget_set_padding(el_val_t handle, el_val_t top, el_val_t right,
+ el_val_t bottom, el_val_t left);
+void __widget_set_width(el_val_t handle, el_val_t width);
+void __widget_set_height(el_val_t handle, el_val_t height);
+void __widget_set_flex(el_val_t handle, el_val_t flex);
+void __widget_set_corner_radius(el_val_t handle, el_val_t radius);
+void __widget_set_disabled(el_val_t handle, el_val_t disabled);
+void __widget_set_hidden(el_val_t handle, el_val_t hidden);
+
+/* Layout / tree */
+void __widget_add_child(el_val_t parent, el_val_t child);
+void __widget_remove_child(el_val_t parent, el_val_t child);
+void __widget_destroy(el_val_t handle);
+
+/* Events */
+void __widget_on_click(el_val_t handle, el_val_t fn_name);
+void __widget_on_change(el_val_t handle, el_val_t fn_name);
+void __widget_on_submit(el_val_t handle, el_val_t fn_name);
+
+/* Manifest reader */
+el_val_t __manifest_read(el_val_t path);
+
+#endif /* EL_TARGET_SDL2 */
diff --git a/ui/examples/native-hello/build-docker/runtime/el_runtime.c b/ui/examples/native-hello/build-docker/runtime/el_runtime.c
new file mode 100644
index 0000000..a735c9f
--- /dev/null
+++ b/ui/examples/native-hello/build-docker/runtime/el_runtime.c
@@ -0,0 +1,11733 @@
+/*
+ * 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 -lcurl -lpthread -o .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
+#include /* strcasecmp */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include