diff --git a/Dockerfile b/Dockerfile index 05fd0c0..03a7fa2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,16 +13,47 @@ # Run: # docker run -p 8080:8080 neuron-landing -# ── Stage 1: compile ────────────────────────────────────────────────────────── +# ── Stage 1a: build liboqs from source ──────────────────────────────────────── +# Debian bookworm doesn't ship liboqs, so we build it ourselves. liboqs is +# small (~5MB compiled, static) and pinned to a release tag for reproducibility. +# Built static, so the runtime image doesn't need a separate liboqs.so. +FROM debian:bookworm-slim AS oqs-builder + +ARG LIBOQS_VERSION=0.10.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential cmake ninja-build git \ + libssl-dev ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone --depth 1 --branch ${LIBOQS_VERSION} \ + https://github.com/open-quantum-safe/liboqs.git /src/liboqs \ + && cmake -S /src/liboqs -B /src/liboqs/build -GNinja \ + -DCMAKE_INSTALL_PREFIX=/opt/oqs \ + -DOQS_BUILD_ONLY_LIB=ON \ + -DOQS_DIST_BUILD=ON \ + -DBUILD_SHARED_LIBS=OFF \ + -DOQS_USE_OPENSSL=ON \ + && cmake --build /src/liboqs/build --parallel \ + && cmake --install /src/liboqs/build + +# ── Stage 1b: compile El binary ─────────────────────────────────────────────── FROM debian:bookworm-slim AS builder RUN apt-get update \ && apt-get install -y --no-install-recommends \ build-essential \ libcurl4-openssl-dev \ + libssl-dev \ ca-certificates \ && rm -rf /var/lib/apt/lists/* +# Pull in liboqs headers + static archive from the previous stage. The +# archive is ~5MB; we link it statically so the runtime image stays slim +# and we don't need a second apt install in stage 2. +COPY --from=oqs-builder /opt/oqs /opt/oqs + WORKDIR /build COPY runtime/el_runtime.c runtime/el_runtime.h ./ COPY server.c ./ @@ -31,7 +62,20 @@ COPY server.c ./ # functions (e.g. handle_request) via dlsym(RTLD_DEFAULT, name), and on # glibc that only sees the executable's own symbols if they were exported # by the linker. macOS exports them by default, Linux doesn't. -RUN cc -std=c11 -O2 -rdynamic -I . -o landing server.c el_runtime.c -lcurl -lpthread -ldl -lm +# +# liboqs is an optional runtime dependency; el_runtime.c gates all pq_* +# entry points behind __has_include(). With the headers + .a +# from the oqs-builder stage in /opt/oqs, the post-quantum primitives +# (Dilithium-3 / ML-DSA-65 signatures, Kyber-768 / ML-KEM-768 KEM, the +# hybrid X25519+Kyber handshake, and SHA3-256) are all available. +# -lcrypto pulls in OpenSSL for X25519 + the symmetric primitives liboqs +# itself uses internally; libcurl4-openssl-dev already pulls libssl-dev +# transitively but we're explicit. +RUN cc -std=c11 -O2 -rdynamic \ + -I . -I/opt/oqs/include \ + -L/opt/oqs/lib \ + -o landing server.c el_runtime.c \ + -lcurl -lpthread -ldl -lm -loqs -lcrypto # `strip` would discard .symtab but keeps .dynsym (which is what -rdynamic # populated and what dlsym actually looks at), so it'd be safe — but the # binary is small enough that the few MB saved isn't worth the next person @@ -40,9 +84,13 @@ RUN cc -std=c11 -O2 -rdynamic -I . -o landing server.c el_runtime.c -lcurl -lpth # ── Stage 2: runtime ────────────────────────────────────────────────────────── FROM debian:bookworm-slim +# libssl3 supplies libcrypto.so.3 for the X25519 + symmetric primitives the +# linked binary needs. liboqs itself is statically linked into the binary +# (built that way in stage 1a) so there's nothing extra to ship for it. RUN apt-get update \ && apt-get install -y --no-install-recommends \ libcurl4 \ + libssl3 \ ca-certificates \ && rm -rf /var/lib/apt/lists/* \ && groupadd -r landing && useradd -r -g landing landing diff --git a/build.sh b/build.sh index ba9d72f..f9f7543 100755 --- a/build.sh +++ b/build.sh @@ -25,7 +25,7 @@ set -euo pipefail cd "$(dirname "$0")" LANDING_DIR=$(pwd) -EL_HOME="${EL_HOME:-${LANDING_DIR}/../../../foundation/el}" +EL_HOME="${EL_HOME:-${LANDING_DIR}/../../foundation/el}" ELC="${EL_HOME}/dist/platform/elc" RUNTIME_SRC="${EL_HOME}/el-compiler/runtime" diff --git a/build/elc.err b/build/elc.err new file mode 100644 index 0000000..e69de29 diff --git a/build/landing b/build/landing new file mode 100755 index 0000000..3b30294 Binary files /dev/null and b/build/landing differ diff --git a/build/landing-combined.el b/build/landing-combined.el new file mode 100644 index 0000000..457f7fd --- /dev/null +++ b/build/landing-combined.el @@ -0,0 +1,4325 @@ +// === styles.el === +// styles.el - Page shell: DOCTYPE, , CSS, and the minimal +// inline + + +" +} + +// === founding_badge.el === +// founding_badge.el - Founding Member badge component. +// +// Renders the certificate-style badge with guilloché background, +// navy colour scheme, and member number. Matches the React archive design. +// Used on the post-purchase success page and as a shareable /founding-badge route. + +fn founding_badge(member_number: Int) -> String { + let num_str: String = int_to_str(member_number) + let show_number: Bool = member_number > 0 + let number_html: String = if show_number { + "
+ No. +

" + num_str + "

+

of 1,000

+
" + } else { + "

Your number awaits

" + } + return " +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + NEURON TECHNOLOGIES · FOUNDING MEMBER · NEURON TECHNOLOGIES · FOUNDING MEMBER + NEURON TECHNOLOGIES · FOUNDING MEMBER · NEURON TECHNOLOGIES · FOUNDING MEMBER + + + +
+

Founding Member

+
+
+ \"Neuron\" +
+ " + number_html + " +
+
+" +} + +fn founding_badge_css() -> String { + return " + +" +} + +// === nav.el === +// components/nav.el — Top navigation bar. +// +// Responsive: desktop shows full link bar, ≤1060px collapses to hamburger. +// Hamburger toggles .nav-mobile panel. Closes on link click or outside click. + +fn nav() -> String { + return " + + + +" +} + +// === hero.el === +// components/hero.el - Hero section. +// +// Full-bleed hero with headline, sub-copy, and two CTAs. +// Glow orbs are pure CSS absolute-positioned divs. +// Entrance animations are CSS keyframes with class-based delays. + +fn hero() -> String { + return " +
+
+
+ +

One builder. Patented. No permission.

+ +

+ Every AI resets when you close the tab. + I built the one that doesn't. +

+ +

+ Runs on your machine. Remembers everything. Priced below ChatGPT on day one. +

+ + + +
+ Scroll +
+
+
+" +} + +// === pillars.el === +// components/pillars.el - Three core pillars section. +// +// I. Remembers / II. Learns / III. Yours +// Cards use reveal animation class (CSS IntersectionObserver polyfill +// provided by a tiny inline + + +
+ +
+

Local inference

+

Your GPU, already powered on

+

+ When you run inference locally via Ollama, your device's GPU handles it - hardware that's already consuming power. No data center spins up a cluster for your query. No round-trip. No idle servers waiting at scale. +

+
+ +
+

No database server for your data

+

On-device storage

+

+ Your context lives on your device in a purpose-built local storage layer. No cloud database servers running 24/7 to store and serve your conversations. No replication across availability zones. Just your device. +

+
+ +
+

Persistent context = less recomputation

+

No re-explaining. No wasted tokens.

+

+ Neuron surfaces exactly what's relevant for each conversation - no re-deriving who you are from long histories. Shorter, more targeted prompts. 40% fewer tokens per useful outcome. +

+
+ +
+

+ The honest picture: When you use Neuron with BYOK providers (OpenAI, Anthropic, Groq) or Neuron Inference, those queries travel to inference servers - that footprint exists. The savings come from the architecture: persistent memory and local-first design reduce the total computation required to get the same work done. +

+
+ +
+ + + + +" +} + +// === enterprise.el === +// components/enterprise.el - Enterprise section. +// Four capability cards + "Who I work with" box + how-it-works steps. + +fn enterprise() -> String { + return " +
+
+ +
+
+
+ Enterprise +
+
+
+

+ Enterprise-ready from day one. +

+

+ Not an afterthought. Not a future roadmap item. Built into the architecture. +

+
+
+
+ + Enterprise discussions open Q4 2026 +
+

Use the form below · I review every inquiry

+
+
+
+ +
+
+

Private deployment

+
+

Run entirely on your infrastructure. Air-gapped. No data leaves your network. Every employee gets their own Neuron instance - your company's institutional knowledge stays inside your walls.

+
+
+

Institutional memory

+
+

When an employee leaves, their expertise doesn't. Their memory graph persists - their patterns, their domain knowledge, their reasoning - available to the team they built it with.

+
+
+

Team intelligence

+
+

Shared knowledge packages and cross-instance coordination. The collective intelligence of your organization compounds the same way an individual's does.

+
+
+

Compliance architecture

+
+

Custom on-device storage. No cloud database with your data. SOC 2 alignment built into the data model. ExternalSecret-based secrets management. Audit logs at every layer.

+
+
+ +
+

Who I work with

+

I am selective. I built Neuron to expand what people can do - not to help organizations eliminate them. If your interest in this technology is primarily about reducing headcount, I am not your vendor. If it's about making the people you have dramatically more effective, I want to hear from you.

+

This isn't a legal hedge. It's a filter. The Enterprise Agreement makes it binding - but I'm raising it here because I'd rather you know before you reach out.

+ +
+
+

How it works

+
+
+
+

01

+

Express interest

+

Send me a note. I review every inquiry myself - no sales funnel, no SDR. If it's a fit, I respond directly.

+
+
+

02

+

Scoped deployment

+

I work with your team to scope a private deployment - on your infrastructure, in your network, with your security requirements.

+
+
+

03

+

Per-seat licensing

+

Annual per-seat pricing with a volume floor. Custom SLA available. The full Agreement is published before any conversation starts.

+
+
+
+ +
+

The full Enterprise Agreement is published. Read it before reaching out - no NDA required to evaluate the terms.

+ Read the Enterprise Agreement → +
+ + +
+

Express interest

+

I review every inquiry myself. Fill this out honestly - the questions are a filter, not a formality.

+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

+ I review these personally. I'll consider it - but I want to be direct with you: if headcount reduction is in the picture at all, even as a secondary outcome, your chances here are low. That's not a negotiating position. It's how I built this and what I'm willing to support. +

+

+ Neuron isn't the right fit here. I built this to expand what people can do, not to replace them. If that changes, I'm here. +

+
+ + + +
+ +
+ +
+ +
+

Received.

+

I review these personally. If it's a fit, I'll respond directly to your email - usually within a few days.

+
+
+ +
+ +
+
+ + +" +} + +// === mission.el === +// components/mission.el - Origin story + manifesto + problems grid. + +fn mission() -> String { + return " +
+
+ +
+ +
+
+
+ The mission +
+
+

Why I built this

+ +

+ Every AI tool you use today resets when you close the tab. It doesn't know you tomorrow. It doesn't remember what mattered yesterday. It can't grow with you over years. +

+

+ I built Neuron because intelligence should compound. The same way a great mentor gets more valuable the longer you work with them - knowing your context, your patterns, your goals - your AI should too. +

+

+ Neuron is private by design. It runs on your hardware. Your data never leaves your machine. No training on your data. No telemetry. No cloud dependency. +

+

+ This isn't a chat interface. It's the AI that becomes yours. +

+
+ +
+

Why I stopped trying to partner with Big Tech

+

+ I didn't just approach one of the largest technology companies in the world - I got the meeting. Got the NDAs signed. Created deliverables in real time. Showed them industry benchmarks with full auditability. Some of their own people could see exactly what it meant - what I had built and what it could do. +

+

+ They saw it. Seemed to engage meaningfully. Then, within two days, lawyers were engaged. Their stated concern: defending against me accusing them of stealing my IP. I laughed to myself - wait, are they going to steal my IP? - and decided to just finish the project on my own. +

+

+ By the way: everything you see here, and more, is protected by six patents - and more are coming. They know about the patents. Are they going to try? +

+

+ Not: how do we solve this at scale? Not: what does this mean for the people we serve? Their instinct was to protect enterprise revenue and manage legal exposure. The actual human impact of their technology - the people whose lives those enterprises touch - didn't enter the conversation. +

+

+ That's the difference. They're optimizing for the enterprise. I'm building for the people those enterprises are supposed to serve. +

+

+ I told them I could build and distribute this by myself. Maybe they didn't believe me. That meeting was April 22nd, 2026. I'm writing this on April 25th. You're looking at the proof. I hope you'll download it. +

+
+ +
+
+
+ What I'm building against +
+ +
+
+

Synthetic media without accountability

+

Generative AI makes it trivially easy to produce harmful content at scale - and nearly impossible to trace. This is a problem the industry is largely ignoring. I'm not. I'm engaged with it seriously and expect to have answers in place before it becomes unmanageable.

+
+
+

Epistemic collapse

+

AI can now generate persuasive content at any volume, on any position. The next generation is growing up in an environment where signal and noise are becoming indistinguishable. I think deeply about what it means to build tools that contribute to that problem - and how to build ones that don't.

+
+
+

Concentration of inference

+

Four companies control nearly all frontier AI inference. Every query strengthens their position. I think that concentration of power is a structural risk - not just a pricing problem - and I'm building with that in mind.

+
+
+

The accountability gap

+

When an AI agent takes a bad action, there is currently no clear legal or technical accountability. That's going to matter more as agents do more. I take this seriously. I'm building toward answers - not waiting for regulators to force the question.

+
+
+
+ +
+

The industry built tools to make AI easier to use. I'm building tools to make it safer to trust.

+
+
+ +
+
+
+" +} + +// === local_first.el === +// components/local_first.el - Privacy principles / local-first manifesto. +// Left: manifesto text. Right: six sticky principles. + +fn local_first() -> String { + return " +
+
+ +
+
+ Local first +
+ +
+ +
+

You are not the product.

+ +

+ The company that gave you free search built the most powerful ad-targeting machine in history. The one that promised to connect the world optimized it for outrage - because outrage drives engagement, and engagement drives revenue. The one that gave everyone a voice sold that attention to the highest bidder. These aren't accidents. They're the business model. +

+

+ I've watched this play out across two decades. Every free product is the same transaction: something useful in exchange for something you didn't know you were selling - your attention, your behavior, your future choices. The product is always “free.” The price is always you. +

+

+ The harm is real. Teenage depression rates tracked the rise of algorithmic social feeds. Political polarization accelerated when engagement algorithms learned that outrage outperformed nuance. Billions of people had their data harvested, leaked, and weaponized - often without knowing. Democratic processes were manipulated at scale. And in every case, the companies responsible kept growing. +

+

+ Now AI is doing the same thing - faster and deeper. Your queries train their models. Your thought patterns become datasets. The way you reason, what you struggle with, what you're afraid of - it's all captured. You get a useful tool. They get a map of your mind. +

+
+

+ Neuron is a direct rejection of that model. It runs on your machine. Your memory never leaves. I don't sell data, serve ads, or profile you. The only thing I sell is the software - and once you have it, it's yours. +

+
+
+ +
+ +
+

Your machine. Full stop.

+

Neuron runs on your hardware. The memory graph, the agent loop, every conversation - none of it leaves your machine. Not to my servers. Not to anyone's.

+
+ +
+

No training on your data.

+

Your queries don't improve a model you don't own. Your patterns aren't analyzed to serve you better ads. Your context belongs to you - not a training pipeline.

+
+ +
+

No ads. Ever.

+

Not on the free tier. Not on paid. Not in any future version. Ads require surveillance. Surveillance requires your data. I'm not building that.

+
+ +
+

No telemetry for local use.

+

When Neuron runs locally, I don't collect usage data. When you opt into Neuron cloud services - sync, backup, inbox - those services use the data they need to function. Nothing more.

+
+ +
+

Nothing to breach.

+

I can't be hacked for your data because I don't have it. I can't be subpoenaed for your conversations because I've never seen them. I can't expose what I've never held. Your data living on your machine isn't just a privacy stance - it's a security one.

+
+ +
+

Unreadable even if taken.

+

Everything Neuron touches is encrypted with post-quantum cryptography - ML-KEM for key exchange, ML-DSA for signatures. Both are NIST-finalized standards (FIPS 203/204), already deployed at scale across the web. Designed to withstand quantum computers, not just the ones that exist today.

+
+ +
+ +
+ +
+

The industry remembers you for them.

+

Neuron remembers you for you.

+

Local-first isn't a feature. It's a commitment.

+
+ +
+
+" +} + +// === pricing.el === +// components/pricing.el - Three pricing cards: Free / Professional / Founding Member. +// Accepts the founding counter values as parameters so main.el can inject +// server-side rendered counts without JS. + +fn founding_spots_html(sold: Int, total: Int) -> String { + let remaining: Int = total - sold + let pct: Int = (sold * 100) / total + let pct_str: String = int_to_str(pct) + let remaining_str: String = int_to_str(remaining) + let sold_str: String = int_to_str(sold) + let total_str: String = int_to_str(total) + return " +
+

Only " + remaining_str + " left

+
+

" + sold_str + " of " + total_str + " claimed

+
+" +} + +fn pricing(sold: Int, total: Int) -> String { + let spots_html: String = founding_spots_html(sold, total) + let remaining: Int = total - sold + let pct: Int = (sold * 100) / total + let pct_str: String = int_to_str(pct) + let remaining_str: String = int_to_str(remaining) + let total_str: String = int_to_str(total) + + return " +
+
+ +
+
+
+ Pricing +
+
+

Own it. Don't rent it.

+

Neuron inference on every paid plan. Cheaper than OpenAI, Anthropic, and Google - on day one.

+
+ +
+ +
+

Free

+
+ $0 + forever +
+

Start building your memory. No card required.

+
    +
  • -Persistent memory - never resets
  • +
  • -Neuron inference - metered, priced below OpenAI and Anthropic
  • +
  • -Local inference via Ollama
  • +
  • Local models may give lower quality responses. Neuron inference is cheap - give it a try.
  • +
  • -Bring your own API keys (optional)
  • +
  • -Unlimited projects
  • +
  • -3 marketplace plugins included
  • +
  • -Core built-in capabilities
  • +
+
+ +
+ +
+

Professional

+
+ $19 + / month +
+

Full access. Neuron inference - cheaper than what you're paying now.

+
    +
  • -Neuron inference - metered, below OpenAI and Anthropic rates
  • +
  • -ChatGPT Plus is $20/mo and forgets you every session. Neuron remembers everything and costs less per token.
  • +
  • -Everything in Free
  • +
  • -Unlimited projects
  • +
  • -Full plugin marketplace
  • +
  • -Advanced integrations - IDE, Slack, and more
  • +
  • -Early access to new features
  • +
+
+
+ +
+
+ +
+

Founding Member

+
+ $199 + lifetime +
+

Pay once. Neuron inference forever. No subscription, ever.

+ + " + spots_html + " + +
    +
  • -Neuron inference - priced below competitors, forever, as it improves
  • +
  • -Everything in Professional - forever
  • +
  • -Never pay again - lifetime updates included
  • +
  • -Founding member badge in the app
  • +
  • -Private community - direct line to the team
  • +
  • -Shape the roadmap - your votes carry more weight
  • +
  • -Beta features before general release
  • +
  • -Name in the credits
  • +
+ +
+
+ +
+
+ +
+ +
+
+

Founding Member Spots

+
+ " + remaining_str + " + remaining of " + total_str + " +
+
+
+ +
+ +

+ Neuron inference on all plans  ·  Bring your own keys if you want  ·  Your data stays yours  ·  Same support for every user +

+ +
+

For families

+

+ Minors require a parent account. + Children and teens can't create accounts on their own - a parent or guardian creates the family account, configures access, and remains in the loop. The minor's experience is fully private from everyone except the trusted adult who set it up. +

+
+ +
+
+" +} + +// === viral.el === +// components/viral.el - Share / referral section. +// Referral link card, invite form (mailto: href, no JS required), +// copy hooks (links to X/Twitter), and LinkedIn share button. +// The "Copy" buttons on the hook items are omitted since we have no JS - +// the tweet links still work, giving users a way to share. + +fn viral() -> String { + return " +
+
+ +
+ +
+
+ Share +
+

Share this with someone who'd get it.

+ +
+ +
+ +
+

Your referral link

+

Earn 3 months free when a friend signs up using your link. Your unique referral code lives in your account.

+ Get your referral link → +
+ +
+

Invite a friend

+

Know someone who'd get it?

+
+ + Send invite +
+

Opens your email client with a pre-written message

+
+ +
+ +
+

Ready to share

+

Copy and paste, or share directly.

+ +
+ +
+

Every AI I'd used forgot me the moment I closed the tab. This one doesn't. It compounds. neurontechnologies.ai

+
+ X +
+
+ +
+

One person built this in 72 hours after a Big Tech meeting went sideways. The AI that remembers you. neurontechnologies.ai

+
+ X +
+
+ +
+

Your AI runs on your machine, knows your full history, and gets sharper the longer you use it. No cloud. neurontechnologies.ai

+
+ X +
+
+ +
+ + + + Share on LinkedIn + +
+ +
+ +
+
+
+" +} + +// === footer.el === +// components/footer.el - Site footer. + +fn footer() -> String { + return " + +" +} + +// === about.el === +// about.el - About page for Neuron landing. +// +// Written from Will's first-person perspective, in his memoir register: +// flowing connected sentences, specificity as credibility, emotionally +// hard things stated in a single clause and moved past. Short sentences +// are structural punctuation - used sparingly, at genuine rupture only. + + +fn about_page() -> String { + return nav() + " + +
+
+ +

About

+

+ Hi. I'm Will. +

+
+ + +
+ \"Will +
+

+ I grew up in Fort Smith, Arkansas, in the kind of instability where home is a moving target - roughly thirty addresses before I was fifteen, parents struggling with addiction, the material precarity that comes with all of that. I left home at fifteen, stayed with friends until I finished high school, found my way to college. At fourteen I'd already found software, writing C++ at the public library because it was the first thing in my life that responded to precision with correctness, and that property turned out to matter more to me than almost anything else. +

+
+
+ + +
+

+ I dropped out of college, worked, went back as an adult to finish my degree, and built my skills across nearly twenty years and every kind of organization - international consulting, early-stage startups, Fortune 5 enterprises. Logistics, retail, entertainment, hospitality, industrial automation, insurance, healthcare, financial services. I trained under Juval Löwy at IDesign and worked with him as a consultant from 2015 to 2021, which is where I learned what it actually means to practice software engineering as a discipline rather than an improvisation. +

+
+ + +
+

+ Software shouldn't be hard. The complexity should live in the problem domain - not in the tools and processes we impose on ourselves. +

+
+ + +
+

What I saw

+

+ Across nearly twenty years I watched software get built at organizations with real stakes and real consequences, and I watched AI go from promise to product - watched the same mistake get made at each iteration: tools built to serve the organization's needs, not the person's. Engagement over relationship. Features over memory. Policies where values should be. The fundamental premise that you are a user, not a person, has been so thoroughly baked into the architecture of every major AI system that it doesn't register as a choice anymore. It's treated as the natural condition of the technology. +

+

+ It is not. It is a design decision. And it is the wrong one. +

+
+ +
+ + +
+

What I built

+

+ Neuron is what I built in response to that. Not a startup in the traditional sense - no team, no funding, no press release - one person, nearly two years of work, and a conviction that this can be done differently. I wrote the memory architecture, I built the inference infrastructure, because the tools that existed weren't sufficient for what I was trying to build and so I built those too. +

+

+ Use it long enough and you'll understand why I couldn't have gotten there on top of existing infrastructure. Some things have to be built from the ground up to be built right. +

+
+ + +
+

What I believe

+

+ AI has genuine potential to free people to do work that actually matters to them - not to create engagement loops, not to harvest attention, but to actually serve the person sitting in front of it. That potential is almost entirely unrealized, not because the technology isn't capable, but because the incentives that shaped it were never oriented toward the person. +

+

+ Build AI that earns the trust it's given. +

+

+ I don't know if Neuron will work at the scale I'm imagining. But I know it's worth finding out, and I know I'm not going back to the other way of building things. +

+
+ +
+ + +
+

+ Neuron opens to founding members on May 1st. 1,000 spots. That's how it starts. +

+ + Join as a founding member → + +
+ +
+
+ + +" +} + +// === terms.el === +// components/terms.el - Consumer Terms of Service page. +// Returns complete HTML using the shared page shell from styles.el. + + +fn terms_page() -> String { + return page_open() + nav() + terms_body() + page_close() +} + +fn terms_body() -> String { + return " +
+
+ ← Neuron +
+
+

Legal

+

Terms of Service

+

Effective May 1, 2026  ·  Neuron, LLC

+
+ +
+
+ § 1 +

Using Neuron

+
+

Using Neuron means you've read these terms and agree to them. That's it. No buried consent, no dark patterns. If something here doesn't sit right with you, email us before using the product - we'll talk through it.

+

These terms cover every version of Neuron: free, Professional, and Founding Member. Enterprise use has its own agreement. If you're deploying Neuron for an organization, that's the one you want.

+
+ +
+
+ § 2 +

What You Can Do With It

+
+

You can install and use Neuron on devices you own or control. That's the license. Personal, non-transferable.

+

The free tier doesn't expire. Paid licenses are what they say they are. Founding Member licenses are perpetual - you bought it, you own it. I won't change that.

+

You can't resell it, sublicense it, or redistribute it without asking first. If you want to do something creative with it, ask. The answer might be yes.

+
+ +
+
+ § 3 +

Your Data

+
+

Your memory graph, your conversations, your context - all of it lives on your device. I don't have access to it. I don't collect it. It doesn't move in the course of normal use.

+

When you turn on network features - sync, relay, collaborative tools - only what those features need to run gets transmitted. Not your memory graph, not your conversations, unless you explicitly turn on something that shares them with people you've chosen. I don't sell that data. I don't use it for anything except running the feature you asked for. Turning on a network feature is your consent to that.

+

Network features route data through Neuron's messaging backplane. That data is encrypted end-to-end - we cannot read it - and is not stored or retained after transmission. Participating in the network is your consent to this transient routing.

+

Your data is yours. Nothing in these terms gives me any claim to your content, your memory graph, or anything you create using Neuron.

+
+ +
+
+ § 4 +

Inference

+
+

Neuron routes inference to whatever provider you configure - your own keys for OpenAI, Anthropic, Groq, any compatible endpoint. When you use a third-party provider, your prompts go through their infrastructure. Their terms apply. I don't control what they do with it.

+

Neuron Inference - our own inference layer, live now - doesn't store your requests, doesn't train on them, doesn't share them. A separate data addendum covers the details.

+
+ +
+
+ § 5 +

The Network Layer

+
+

Neuron includes access to a proprietary distributed network infrastructure (Patent Pending: US Provisional 64/036,821) that provides cultivation, recognition, and coordination services for the Neuron AI core. Its architecture, operational logic, and internal protocols are trade secrets. What matters here is what it means for you.

+

The Neuron AI you interact with evolves over time - its state, character, and responses change as its internal development progresses. This is by design. By using Neuron, you acknowledge that: (a) the AI's responses may change as development progresses; (b) Neuron Technologies makes no warranty as to the consistency, stability, or predictability of AI outputs at any given point in its development; and (c) AI outputs are a function of development state, not a fixed specification.

+

When you enable network features, your interactions may be processed by or through Neuron's distributed network infrastructure. All such data is encrypted in transit. Neuron Technologies cannot read, inspect, retain, or produce the contents of network traffic as an architectural constraint - not merely as a policy. Because we cannot read network traffic, Neuron Technologies is not liable for any content, output, or consequence arising from data processed within the network layer, including outputs produced during AI development or content you introduce that is processed by the network.

+

You agree to indemnify and hold harmless Neuron Technologies, its affiliates, and its officers from any claims, damages, or legal proceedings arising from: (a) your reliance on AI outputs during active development periods; (b) content you originate and introduce through network features; (c) your violation of applicable law in connection with network participation; or (d) any attempt to reverse-engineer, probe, circumvent, or replicate proprietary network architecture or AI development mechanisms.

+

The network layer is decentralized by design. Neuron Technologies does not guarantee its availability, continuity, or performance, and may modify, suspend, or discontinue network features at any time. Network participation does not create an agency relationship between participants, and no participant acts on behalf of Neuron Technologies in any capacity.

+
+ +
+
+ § 6 +

What You Can't Do

+
+

Don't use Neuron to generate content designed to harm people, defraud anyone, or break the law.

+

Don't try to reverse-engineer the licensing or security mechanisms.

+

Don't extract or resell proprietary components.

+

If you violate these and you're using cloud services, I can suspend access to those services. I can't touch the local software - once it's on your machine, it's yours to run. But I can close the door on the network.

+
+ +
+
+ § 7 +

Ownership

+
+

Neuron and everything in it is my intellectual property. Six patents and counting. These terms don't transfer any of that to you.

+

Everything you create using Neuron - outputs, memory nodes, artifacts - is yours. I make no claim to it. Not now, not ever.

+
+ +
+
+ § 8 +

No Warranty

+
+

THE SOFTWARE IS PROVIDED "AS IS." NEURON, LLC MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT.

+

Software has bugs. Security has limits. Use your judgment. You use Neuron at your own risk.

+
+ +
+
+ § 9 +

Liability Cap

+
+

TO THE EXTENT PERMITTED BY LAW, NEURON, LLC IS NOT LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE SOFTWARE.

+

If something goes wrong and you have a claim against me, the most I owe you is what you paid in the twelve months before it happened. That's the cap.

+
+ +
+
+ § 10 +

Changes

+
+

I'll update these terms when I need to. Material changes get announced on the site and, where I have your email, directly. Continued use after a change means you accept the new terms.

+

Founding Member licenses are locked. I won't retroactively change what you signed up for.

+
+ +
+
+ § 11 +

Children and Family Accounts

+
+

Children under 13 may only use Neuron as part of a family account established by a parent or legal guardian. By setting up a family account that includes a minor, the parent or guardian provides verifiable consent for the child's use and accepts these terms on the child's behalf.

+

When a child's Neuron instance is part of a family account, certain information - including usage activity and relevant context - may be shared from the child's local instance to the parent's local instance. This sharing happens device-to-device. It does not pass through or get stored on Neuron's servers. The parent's instance receives it; we never see it.

+

Parents control what is visible to them through their local instance and can revoke family account access at any time. If you believe a child is using Neuron outside of a family account, contact legal@neurontechnologies.ai and we will address it promptly.

+
+ +
+
+ § 12 +

Governing Law

+
+

These terms are governed by the laws of Delaware. Disputes go to the courts of Delaware.

+
+ +
+ ← Home + Enterprise Agreement → +
+
+" +} + +// === enterprise_terms.el === +// components/enterprise_terms.el - Enterprise Agreement page. +// Returns complete HTML using the shared page shell from styles.el. + + +fn enterprise_terms_page() -> String { + return page_open() + enterprise_terms_body() + page_close() +} + +fn section(num: String, title: String, body: String) -> String { + return " +
+
+ § " + num + " +

" + title + "

+
+ " + body + " +
" +} + +fn p(text: String) -> String { + return "

" + text + "

" +} + +fn p_last(text: String) -> String { + return "

" + text + "

" +} + +fn p_caps(text: String) -> String { + return "

" + text + "

" +} + +fn enterprise_terms_body() -> String { + return nav() + " +
+
+ ← Neuron +
+
+

Legal

+

Enterprise Agreement

+

Effective May 1, 2026  ·  Neuron, LLC

+
+ +" + section("1", "Parties and Scope", + p("This Enterprise Agreement ("Agreement") is between Neuron, LLC ("Neuron") and the organization entering into an enterprise relationship ("Customer"). It governs all enterprise deployments, including Team, Enterprise, and Private Cloud tiers.") + + p_last("This Agreement supplements the Neuron Terms of Service. In the event of conflict, this Agreement controls for enterprise use. The parties are bound by the Order Form executed at time of deployment, which specifies tier, seat count, term, and pricing.") +) + section("2", "License Grant and Usage Rights", + p("Subject to the terms of this Agreement and timely payment of fees, Neuron grants Customer a limited, non-exclusive, non-transferable, non-sublicensable license to deploy and use the Neuron platform ("Software") solely for Customer's internal business purposes during the Term.") + + p("The license is scoped to the number of authorized seats, devices, or instances specified in the Order Form. Customer may not exceed the licensed scope without executing a seat expansion. Neuron will not invoice retroactively for inadvertent overage of fewer than 10% of contracted seats if Customer notifies Neuron and brings usage into compliance within 30 days.") + + p_last("Customer may not: (a) sublicense, resell, or distribute the Software to third parties; (b) reverse engineer, decompile, or attempt to extract source code or trained model weights; (c) use the Software to build a competing product; or (d) remove or obscure any proprietary notices. Private Cloud deployments may be deployed to Customer's infrastructure but may not be transferred or made available to entities other than Customer's own employees and contractors under appropriate confidentiality obligations.") +) + section("3", "Data and Privacy", + p("Neuron's architecture is designed to minimize data exposure. In private cloud and air-gapped deployments, no data leaves Customer's infrastructure. Neuron has no access to Customer data in these configurations.") + + p("In cloud-hosted deployments, Neuron processes Customer data solely to deliver the contracted services. Customer data is stored in an isolated tenant, is not commingled with other customers' data, and is not used for model training, analytics, or any purpose beyond service delivery. When Customer activates network features - including relay, peer-to-peer sync, or collaborative features - data required to operate those features may be transmitted to other network participants or Neuron infrastructure; such transmission is limited to the minimum required, excludes Customer's memory graph and conversation history unless Customer explicitly enables a sharing feature, and is used only to deliver the activated feature. Network features route data through Neuron's messaging backplane. That data is encrypted end-to-end - Neuron cannot read it - and is not stored or retained after transmission.") + + p("Customer owns all data processed through the platform. Customer data is not Customer's data in the legal sense of the term as used in data processing frameworks - Customer is the controller. Neuron is the processor. Upon termination, Customer data is returned or destroyed within 30 days at Customer's election. We do not retain copies.") + + p_last("Where required by applicable law, Neuron will execute a Data Processing Agreement (DPA) as a supplement to this Agreement. Contact enterprise@neurontechnologies.ai to initiate. Neuron will notify Customer within 72 hours of becoming aware of any confirmed breach affecting Customer data.") +) + section("4", "Values Alignment", + p("Neuron is built on the principle that AI should expand human capability, not replace it. We are selective about the organizations we work with.") + + p("Customer represents that it does not intend to use Neuron primarily as a tool to eliminate employee positions, reduce headcount through automation in ways that harm workers, or otherwise use the platform in ways inconsistent with the welfare of the people who work in Customer's organization.") + + p("Neuron reserves the right to decline, not renew, or terminate any enterprise agreement where Neuron determines, in its reasonable judgment, that the Customer's use of the platform is fundamentally misaligned with these principles. Termination under this section is subject to the notice and cure provisions of § 10.") + + p_last("We will not enter into agreements with organizations whose primary business model, as Neuron determines in good faith, depends on practices that systematically harm workers, small businesses, or communities. This is not a standard clause in most software agreements. We mean it.") +) + section("5", "Service Levels and Support", + p("For cloud-hosted deployments, Neuron targets 99.9% monthly uptime, excluding scheduled maintenance windows (communicated at least 48 hours in advance) and circumstances outside Neuron's reasonable control. In the event Neuron fails to meet this target in a given calendar month, Customer may request a service credit equal to 5% of that month's fees for each full percentage point of availability below the target, up to a maximum of 30% of monthly fees. Credits are the sole and exclusive remedy for availability failures.") + + p("For on-premises and private cloud deployments, Customer is responsible for infrastructure availability. Neuron's SLA obligations apply only to software defects in the distributed build, not to Customer's infrastructure choices.") + + p_last("Support is provided by email at enterprise@neurontechnologies.ai. Neuron will acknowledge critical issues (complete service outage or data loss risk) within 4 business hours and provide a status update within 24 hours. Standard issues are acknowledged within 2 business days. Neuron does not guarantee resolution timelines for issues requiring platform changes.") +) + section("6", "Security and Compliance", + p("Neuron implements security controls commensurate with enterprise software handling sensitive user data. These include: encryption in transit (TLS 1.2+) and at rest for cloud-hosted services; role-based access controls; audit logging at the infrastructure layer; and regular internal security reviews.") + + p("Neuron will pursue SOC 2 Type II certification and will provide Customer with the most recent available report upon request under a signed NDA. Until certification is achieved, Neuron will make available a written summary of security controls upon request.") + + p_last("Customer is responsible for: (a) maintaining the security of credentials and API keys used to access the platform; (b) configuring access controls for Customer's own users; (c) compliance with applicable law with respect to the data Customer chooses to input into the platform; and (d) conducting its own security evaluation appropriate to its risk profile before deployment in regulated environments.") +) + section("7", "Confidentiality", + p("Each party ("Receiving Party") agrees to protect the other party's ("Disclosing Party") Confidential Information with the same degree of care it uses to protect its own confidential information, but no less than reasonable care. "Confidential Information" means any non-public information disclosed by the Disclosing Party that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure.") + + p("Confidential Information does not include information that: (a) becomes publicly known through no breach by the Receiving Party; (b) was already known to the Receiving Party before disclosure; (c) is received from a third party without restriction; or (d) was independently developed by the Receiving Party without use of the Confidential Information.") + + p_last("These obligations survive termination of this Agreement for three years. Receiving Party may disclose Confidential Information to the extent required by law or court order, provided that Receiving Party gives Disclosing Party prompt written notice and cooperates with any effort to obtain a protective order.") +) + section("8", "Intellectual Property", + p("Neuron retains all right, title, and interest in and to the Software, including all improvements, updates, and derivative works, and all intellectual property rights therein. No title to or ownership of the Software transfers to Customer under this Agreement.") + + p("Customer retains all right, title, and interest in and to Customer's data, including inputs, memory graphs, and outputs generated through Customer's use of the platform. Neuron does not claim ownership of Customer's outputs. Customer is responsible for evaluating the accuracy and appropriateness of outputs before relying on them.") + + p_last("This Agreement does not grant Customer any right to use Neuron's trademarks, trade names, or logos except as expressly authorized in writing. Neuron Aligned Partner certification (§ 14) includes a limited right to use the partner designation as specified in the Partner Addendum.") +) + section("9", "Indemnification", + p("Neuron will defend Customer against third-party claims that the Software, as provided by Neuron and used in accordance with this Agreement, infringes a valid patent, copyright, or trademark, and will indemnify Customer for damages finally awarded in such a proceeding or agreed in settlement. This obligation does not apply to claims arising from: (a) Customer's modification of the Software; (b) Customer's combination of the Software with third-party products or data not provided by Neuron; or (c) Customer's use of the Software in violation of this Agreement.") + + p_last("Customer will defend Neuron against third-party claims arising from Customer's use of the platform in violation of applicable law, this Agreement, or the rights of third parties, and will indemnify Neuron for damages finally awarded or agreed in settlement. Each party must promptly notify the other of any claim for which indemnification may be sought and must cooperate reasonably in the defense.") +) + section("10", "Term and Termination", + p("This Agreement begins on the Effective Date specified in the Order Form and continues for the initial term stated therein, typically twelve months. Unless either party provides written notice of non-renewal at least 30 days before the end of the then-current term, this Agreement automatically renews for successive one-year terms at the pricing in effect at renewal.") + + p("Either party may terminate this Agreement for material breach upon 30 days' written notice if the breach remains uncured at the end of that period. Neuron may terminate immediately upon written notice if Customer breaches § 2 (unauthorized use of the Software) or § 7 (confidentiality obligations).") + + p("Neuron may terminate this Agreement under § 4 (Values Alignment) with 60 days' written notice if Neuron determines in good faith that Customer's use of the platform is materially and persistently misaligned with the principles stated therein. Neuron will provide Customer the opportunity to respond and demonstrate alignment before termination takes effect.") + + p_last("Upon termination: (a) all licenses immediately terminate; (b) Customer must cease use of the Software; (c) each party will return or destroy the other's Confidential Information upon request; and (d) Neuron will make Customer's data available for export for 30 days, after which it will be destroyed. Sections 3 (data ownership and destruction), 7 (confidentiality), 8 (intellectual property), 9 (indemnification), 11 (limitation of liability), and 13 (governing law) survive termination.") +) + section("11", "Limitation of Liability", + p_caps("NEURON'S TOTAL LIABILITY UNDER THIS AGREEMENT SHALL NOT EXCEED THE FEES PAID BY CUSTOMER IN THE TWELVE MONTHS PRECEDING THE CLAIM.") + + p_caps("NEITHER PARTY SHALL BE LIABLE FOR INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, EVEN IF ADVISED OF THEIR POSSIBILITY.") + + p_last("These limitations do not apply to: (a) Customer's payment obligations; (b) breaches of § 7 (confidentiality); (c) a party's indemnification obligations under § 9; or (d) a party's gross negligence or willful misconduct.") +) + section("12", "Warranties and Disclaimers", + p("Neuron warrants that: (a) the Software will perform materially in accordance with its published documentation during the Term; (b) Neuron has the authority to enter into this Agreement and grant the licenses herein; and (c) to Neuron's knowledge, the Software does not knowingly infringe any third-party intellectual property rights.") + + p("Customer's exclusive remedy for a warranty breach under (a) is for Neuron to use commercially reasonable efforts to correct the non-conformity, or if correction is not commercially practicable within 30 days, to terminate the Agreement and receive a pro-rata refund of prepaid fees for the unused portion of the Term.") + + p_caps("EXCEPT AS EXPRESSLY SET FORTH IN THIS SECTION, THE SOFTWARE IS PROVIDED "AS IS." NEURON DISCLAIMS ALL OTHER WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT.") + + p_last("AI-generated outputs are probabilistic and may be inaccurate, incomplete, or inappropriate for specific contexts. Customer is responsible for reviewing outputs before relying on them for any consequential decision. Neuron does not warrant that outputs will be accurate, complete, or suitable for any particular purpose.") +) + section("13", "Governing Law and Disputes", + p("This Agreement is governed by the laws of the State of Delaware, without regard to conflict of law principles.") + + p_last("The parties agree to attempt to resolve disputes through good-faith negotiation before initiating formal proceedings. If negotiation fails within 30 days of written notice of the dispute, disputes shall be resolved by binding arbitration under AAA Commercial Arbitration Rules in Wilmington, Delaware, except that either party may seek injunctive relief in court for IP or confidentiality violations without first arbitrating.") +) + " + + +
+
+ § 14 +

Neuron Aligned Partner Program

+
+ +

Participation in the Neuron Aligned Partner Program is entirely voluntary. No enterprise customer is required to participate, and the standard enterprise relationship under this Agreement is not conditioned on it.

+ +

An organization that chooses to participate executes a Neuron Aligned Partner Addendum ("Partner Addendum"), a separate document supplementing this Agreement. In the Partner Addendum, Customer makes the following certification:

+ +
+

"We are deploying Neuron to make our people more capable, not to eliminate them. Reducing headcount is not a primary objective of this deployment. We commit to using this technology in a manner consistent with the welfare of our workforce, and we will notify Neuron if our intentions change in ways that materially contradict this commitment."

+
+ +

Partners who execute the Partner Addendum receive:

+
    +
  • Preferential per-seat pricing - specific discount terms are disclosed at the time of certification and reflected in the Order Form
  • +
  • Membership in the Neuron Aligned Partner Network, a private community of organizations that have made the same commitment
  • +
  • Optional public listing as a Neuron Aligned Partner - a signal to employees and the market that AI is being used to expand human capability, not eliminate it
  • +
  • Priority consideration in the enterprise roadmap - Aligned Partners are given more weight in feature prioritization and deployment planning
  • +
  • Direct access to Neuron leadership for roadmap input and partnership discussions
  • +
+ +

The certification is self-attested and made in good faith. Neuron does not audit deployment usage to verify the commitment, but relies on the integrity of the partner's representation. If Neuron determines, based on credible evidence, that a partner's use of the platform is materially inconsistent with the certification, Neuron may revoke partner status with 30 days' notice and adjust pricing to standard enterprise rates at the next renewal.

+ +

To inquire about the Partner Program, contact enterprise@neurontechnologies.ai with subject line "Aligned Partner."

+
+ +" + section("15", "General Provisions", + p("This Agreement, together with the Order Form and any addenda, constitutes the entire agreement between the parties with respect to its subject matter and supersedes all prior negotiations, representations, and agreements. Any amendment must be in writing and signed by authorized representatives of both parties.") + + p("If any provision of this Agreement is found unenforceable, it will be modified to the minimum extent necessary to make it enforceable, and the remaining provisions will continue in full force. Failure to enforce any provision is not a waiver of the right to enforce it later.") + + p("Neither party may assign this Agreement without the other's written consent, except that Neuron may assign to an acquirer of all or substantially all of its business or assets without consent, provided the acquirer assumes all obligations under this Agreement.") + + p_last("Notices must be in writing and sent to enterprise@neurontechnologies.ai for Neuron, or to the email address specified in the Order Form for Customer. Notices are effective on the day of confirmed receipt.") +) + " + +
+ ← Home + Consumer Terms → +
+
+" +} + +// === checkout.el === +// checkout.el - Integrated Stripe checkout page. +// +// Uses Stripe Payment Element (not hosted checkout) so the entire +// purchase experience lives on neurontechnologies.ai. The page is +// designed to match the Neuron brand - navy, clean, no Stripe chrome. +// +// Flow: +// 1. GET /checkout?plan=founding|professional +// 2. Page JS calls POST /api/payment-intent → { client_secret, publishable_key, plan } +// 3. Stripe Payment Element mounts into #payment-element +// 4. User fills name, email, card - submits +// 5. stripe.confirmPayment() → redirects to /marketplace/success + +fn checkout_page(plan: String, pub_key: String) -> String { + let is_founding: Bool = str_eq(plan, "founding") + + let plan_name: String = if is_founding { "Founding Member" } else { "Professional" } + let plan_price: String = if is_founding { "$199" } else { "$19 / month" } + let plan_desc: String = if is_founding { + "Pay once. Neuron inference forever. No subscription, ever." + } else { + "Full access. Neuron inference - cheaper than what you're paying now." + } + let plan_cadence: String = if is_founding { "one-time" } else { "billed monthly" } + + let features_html: String = if is_founding { + "
  • Neuron inference - priced below competitors, forever
  • +
  • Everything in Professional - forever
  • +
  • Never pay again - lifetime updates included
  • +
  • Founding member badge in the app
  • +
  • Private community - direct line to the team
  • +
  • Shape the roadmap - your votes carry more weight
  • +
  • Beta features before general release
  • +
  • Name in the credits
  • " + } else { + "
  • Neuron inference - metered, priced below OpenAI and Anthropic
  • +
  • Persistent memory - never resets
  • +
  • Unlimited projects
  • +
  • Full plugin marketplace
  • +
  • IDE, Slack, and more integrations
  • +
  • Early access to new features
  • " + } + + return " + + +
    +
    + + +
    +

    Your order

    + +
    +

    " + plan_name + "

    +

    " + plan_desc + "

    +
    + " + plan_price + " + " + plan_cadence + " +
    +
    + +
    + +
      + " + features_html + " +
    + +

    + Your data stays yours. Runs locally. No telemetry. +

    +
    + + +
    +

    Payment

    + +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    Loading payment form…
    +
    +
    + +
    + + + +

    + + + + + Secured by Stripe  ·  256-bit TLS  ·  PCI DSS compliant +

    + +
    +
    + +
    +
    + + + + + + + +" +} + +// === safety.el === +// components/safety.el - Safety architecture section. +// +// Describes the Hard Bell architecture and genuine protective design. +// The Hard Bell is for everyone - not just children. Any user can +// designate a trusted contact that bypasses the default notification +// path when a signal warrants it. + +fn safety() -> String { + return " +
    +
    + +
    + +
    +
    +
    + Safety +
    + +

    + Safety built in.
    Not bolted on. +

    + +

    + Most AI products treat safety as a content filter. Block a list of topics, add a disclaimer, call it done. That's not safety. That's liability management. +

    + +

    + Real safety means thinking about who might need help and what help actually looks like in a crisis - for anyone, not just children. The person closest to you is sometimes the source of the problem. A system that routes every distress signal to "your primary contact" can alert the very person you need protection from. +

    + +

    + I built something different. I called it the Hard Bell. +

    +
    + +
    + +
    +

    Hard Bell

    +

    A trusted contact the threat can't intercept

    +

    + Any user can designate a Hard Bell contact - a friend, a relative, a colleague - set up in a calm moment, independent of any shared account. When a signal warrants it, that contact is reached directly. It works the same way for everyone: adult, teen, or child. +

    +
    + +
    +

    Emergency routing

    +

    Emergency services first - not notification

    +

    + Physical danger and crisis signals route to emergency services and crisis lines. Neuron evaluates the content of the signal, not the account type. No one in your contact list can disable or redirect this path. +

    +
    + +
    +

    Family accounts

    +

    Oversight without surveillance

    +

    + For family accounts, parents see what they need to see. A child's conversations remain private unless a wellbeing signal triggers notification - and even then, the routing logic accounts for the possibility that the parent is the source of harm. +

    +
    + +
    + +
    + +
    +

    + Required before you can use Neuron. You must designate a Hard Bell contact during setup - before anything else. This is not a settings page you visit later. It happens first. +

    +

    + If you don't have someone to designate, you can use 988 - the Suicide & Crisis Lifeline - as your Hard Bell contact. The system will accept it. The point is that no one goes in without a line out. +

    +

    + I'm also establishing a Neuron crisis line - free, 24/7, staffed. Because I recognize that some people don't have anyone. The technology shouldn't make that worse. It should be the thing that catches you when nothing else does. +

    +
    + +
    +
    +" +} + +// === main.el === +// main.el - Neuron landing page server. +// +// Serves the Neuron marketing landing page at port 3001. +// Written in El (the Engram language). Runs on the El VM (elvm). +// +// The El HTTP server intercepts GET / and serves __html_file__ directly. +// We generate the page at startup with El components, write it to +// src/index.html, and set __html_file__ to that path. +// +// This means the document is always El-generated - never hand-authored. +// The El runtime serves it with the correct Content-Type: text/html header. +// +// Execution: +// el run (builds + executes via the El VM) +// +// Routes: +// GET / → landing page (El-rendered, served by runtime) +// GET /api/health → {"status":"ok"} +// GET /api/founding-count → {"remaining":N,"sold":N,"total":N} +// GET /assets/* → static assets (served by runtime from src/assets/) +// GET /brand/* → brand assets via handle_request +// GET * → 404 JSON (non-/ paths not used by this SPA) + + +// ── Founding counter ────────────────────────────────────────────────────────── + +let FOUNDING_TOTAL: Int = 1000 +let FOUNDING_SOLD: Int = 47 // floor — real count comes from Stripe at startup + +// ── Founding count helpers ───────────────────────────────────────────────────── + +fn get_sold() -> Int { + let s: String = state_get("__founding_sold__") + if str_eq(s, "") { + return FOUNDING_SOLD + } + return parse_int(s) +} + +fn get_total() -> Int { + let s: String = state_get("__founding_total__") + if str_eq(s, "") { + return FOUNDING_TOTAL + } + return parse_int(s) +} + +// fetch_founding_count_stripe — queries Stripe PaymentIntents search for the +// real founding count. Uses the secret key with Bearer auth. Falls back to the +// FOUNDING_SOLD floor if Stripe is unavailable or not yet configured. +fn fetch_founding_count_stripe(stripe_key: String) -> Int { + if str_eq(stripe_key, "") { + return FOUNDING_SOLD + } + let url: String = "https://api.stripe.com/v1/payment_intents/search?query=metadata%5B%27plan%27%5D%3A%27founding%27+AND+status%3A%27succeeded%27" + let resp: String = http_get_auth(url, stripe_key) + if str_eq(resp, "") { + return FOUNDING_SOLD + } + let count: Int = json_get_int(resp, "total_count") + if count <= 0 { + return FOUNDING_SOLD + } + return count +} + +// load_founding_count — reads from persist file first (survives restarts), +// falls back to Stripe query if file doesn't exist yet. +fn load_founding_count(sold_file: String, stripe_key: String) -> Int { + if fs_exists(sold_file) { + let s: String = str_trim(fs_read(sold_file)) + let n: Int = parse_int(s) + if n > 0 { + return n + } + } + return fetch_founding_count_stripe(stripe_key) +} + +// persist_founding_count — writes current sold count to disk so it survives +// server restarts. Called at startup and on every Stripe webhook increment. +fn persist_founding_count(sold: Int) { + let sold_file: String = state_get("__founding_sold_file__") + if !str_eq(sold_file, "") { + fs_write(sold_file, int_to_str(sold)) + } +} + +// ── Page assembly ───────────────────────────────────────────────────────────── + +fn page(sold: Int, total: Int) -> String { + return page_open() + + nav() + + hero() + + pillars() + + how_it_works() + + inference() + + efficiency() + + comparison() + + enterprise() + + mission() + + local_first() + + safety() + + environmental() + + pricing(sold, total) + + viral() + + footer() + + page_close() +} + +// ── Static asset serving ────────────────────────────────────────────────────── + +fn read_asset(abs_path: String) -> String { + let exists: Bool = fs_exists(abs_path) + if !exists { + return "" + } + return fs_read(abs_path) +} + +// ── Request handler ─────────────────────────────────────────────────────────── +// +// NOTE: GET / is intercepted by the El HTTP runtime before reaching this +// function - it serves __html_file__ directly with text/html. +// This handler covers /api/* and /brand/* routes. + +fn handle_request(method: String, path: String, body: String) -> String { + let src_dir: String = state_get("__src_dir__") + + // ── About page ──────────────────────────────────────────────────────────── + if str_eq(path, "/about") { + let about_path: String = state_get("__about_html_file__") + if !str_eq(about_path, "") { + return fs_read(about_path) + } + return "{\"__status__\":404,\"error\":\"not found\"}" + } + + // ── Terms of Service ────────────────────────────────────────────────────── + if str_eq(path, "/legal/terms") { + let terms_path: String = state_get("__terms_html_file__") + if !str_eq(terms_path, "") { + return fs_read(terms_path) + } + return "{\"__status__\":404,\"error\":\"not found\"}" + } + + // ── Enterprise Agreement ────────────────────────────────────────────────── + if str_eq(path, "/legal/enterprise-terms") { + let ent_path: String = state_get("__enterprise_terms_html_file__") + if !str_eq(ent_path, "") { + return fs_read(ent_path) + } + return "{\"__status__\":404,\"error\":\"not found\"}" + } + + // ── Checkout page ───────────────────────────────────────────────────────── + if str_starts_with(path, "/checkout") { + let plan: String = "founding" + if str_contains(path, "plan=professional") { + plan = "professional" + } + let pub_key: String = state_get("__stripe_publishable_key__") + return page_open() + checkout_page(plan, pub_key) + page_close() + } + + // ── Stripe payment intent ───────────────────────────────────────────────── + if str_eq(path, "/api/payment-intent") { + let stripe_key: String = state_get("__stripe_secret_key__") + if str_eq(stripe_key, "") { + return "{\"__status__\":503,\"error\":\"Stripe not configured\"}" + } + let plan: String = "founding" + if str_contains(body, "\"professional\"") { + plan = "professional" + } + let amount: String = "19900" + if str_eq(plan, "professional") { + amount = "1900" + } + let pi_body: String = "amount=" + amount + + "¤cy=usd" + + "&payment_method_types[]=card" + + "&metadata[plan]=" + plan + let response: String = http_post_form_auth( + "https://api.stripe.com/v1/payment_intents", + stripe_key, + pi_body + ) + return response + } + + // ── Health check ────────────────────────────────────────────────────────── + if str_eq(path, "/api/health") { + return "{\"status\":\"ok\",\"service\":\"neuron-landing\"}" + } + + // ── Founding count ──────────────────────────────────────────────────────── + if str_eq(path, "/api/founding-count") { + let sold: Int = get_sold() + let total: Int = get_total() + let remaining: Int = total - sold + let sold_s: String = int_to_str(sold) + let total_s: String = int_to_str(total) + let rem_s: String = int_to_str(remaining) + return "{\"sold\":" + sold_s + ",\"total\":" + total_s + ",\"remaining\":" + rem_s + "}" + } + + // ── Brand assets: /brand/* ──────────────────────────────────────────────── + if str_starts_with(path, "/brand/") { + let rel: String = str_slice(path, 7, str_len(path)) + let abs: String = src_dir + "/brand/" + rel + let content: String = read_asset(abs) + if str_eq(content, "") { + return "{\"__status__\":404,\"error\":\"not found\"}" + } + return content + } + + // ── Stripe checkout ─────────────────────────────────────────────────────── + if str_eq(path, "/api/checkout") { + let stripe_key: String = state_get("__stripe_secret_key__") + if str_eq(stripe_key, "") { + return "{\"__status__\":503,\"error\":\"Stripe not configured\"}" + } + let plan: String = "founding" + if str_contains(body, "\"professional\"") { + plan = "professional" + } + let origin: String = state_get("__origin__") + let price_id: String = "" + let mode: String = "subscription" + if str_eq(plan, "founding") { + price_id = state_get("__stripe_price_founding__") + mode = "payment" + } + if str_eq(plan, "professional") { + price_id = state_get("__stripe_price_professional__") + mode = "subscription" + } + if str_eq(price_id, "") { + return "{\"__status__\":503,\"error\":\"Plan price not configured\"}" + } + let form_body: String = "mode=" + mode + + "&line_items[0][price]=" + price_id + + "&line_items[0][quantity]=1" + + "&success_url=" + origin + "/marketplace/success?session_id={CHECKOUT_SESSION_ID}" + + "&cancel_url=" + origin + "/#pricing" + + "&allow_promotion_codes=true" + + "&metadata[plan]=" + plan + let response: String = http_post_form_auth( + "https://api.stripe.com/v1/checkout/sessions", + stripe_key, + form_body + ) + if str_contains(response, "\"url\"") { + return response + } + return "{\"__status__\":500,\"error\":\"Stripe session creation failed\"}" + } + + // ── Enterprise inquiry ──────────────────────────────────────────────────── + if str_eq(path, "/api/enterprise-inquiry") { + let resend_key: String = state_get("__resend_api_key__") + let name_val: String = if str_contains(body, "\"name\"") { "submitted" } else { "" } + if str_eq(name_val, "") { + return "{\"error\":\"invalid request\"}" + } + // Log to stdout regardless of email delivery + println("[enterprise-inquiry] " + body) + // Send via Resend if key is configured + if !str_eq(resend_key, "") { + let email_body: String = "{\"from\":\"Neuron Enterprise \",\"to\":[\"enterprise@neurontechnologies.ai\"],\"subject\":\"Enterprise Inquiry\",\"text\":" + body + "}" + let resp: String = http_post_auth( + "https://api.resend.com/emails", + resend_key, + email_body + ) + println("[enterprise-inquiry] resend: " + resp) + } + return "{\"received\":true}" + } + + // ── Stripe webhook ──────────────────────────────────────────────────────── + if str_eq(path, "/api/webhooks/stripe") { + if str_contains(body, "checkout.session.completed") { + // Increment founding counter when a founding purchase completes. + if str_contains(body, "\"founding\"") { + let current_sold: Int = get_sold() + let new_sold: Int = current_sold + 1 + state_set("__founding_sold__", int_to_str(new_sold)) + persist_founding_count(new_sold) + println("[webhook] founding sold: " + int_to_str(new_sold)) + } + // Forward to license API for key provisioning. + let license_api: String = state_get("__license_api_url__") + if !str_eq(license_api, "") { + let resp: String = http_post(license_api + "/api/v1/webhooks/stripe", body) + println("[webhook] forwarded to license API: " + resp) + } + } + return "{\"received\":true}" + } + + // ── Checkout success ────────────────────────────────────────────────────── + if str_eq(path, "/marketplace/success") { + let badge_html: String = founding_badge(get_sold()) + let badge_css: String = founding_badge_css() + return page_open() + badge_css + " +
    +

    You're in.

    +

    Welcome to Neuron.

    +

    + Your license is being provisioned. Check your email - your license key and download instructions will be there in the next few minutes. +

    +
    " + badge_html + "
    + Back to home → +
    +" + page_close() + } + + // ── Fallback ────────────────────────────────────────────────────────────── + return "{\"__status__\":404,\"error\":\"not found\"}" +} + +// ── Startup ─────────────────────────────────────────────────────────────────── +// +// Order matters: +// 1. Load Stripe config + origin from env (needed before founding count) +// 2. Seed founding count from file or Stripe API +// 3. Generate all HTML with the real count +// 4. Register with El HTTP runtime and serve + +let src_dir: String = cwd() + "/src" +let html_path: String = src_dir + "/index.html" + +// Stripe config from environment — loaded first so founding count can use it. +let stripe_key: String = env("STRIPE_SECRET_KEY") +let stripe_pub_key: String = env("STRIPE_PUBLISHABLE_KEY") +let stripe_price_founding: String = env("STRIPE_PRICE_FOUNDING") +let stripe_price_professional: String = env("STRIPE_PRICE_PROFESSIONAL") +let license_api_url: String = env("NEURON_LICENSE_API_URL") +let resend_api_key: String = env("RESEND_API_KEY") + +// Origin — drives Stripe redirect URLs; never hardcoded to localhost. +let neuron_origin: String = env("NEURON_ORIGIN") +if str_eq(neuron_origin, "") { + neuron_origin = "https://neurontechnologies.ai" +} + +// Founding count — seeded from persist file or live Stripe query. +let sold_file: String = src_dir + "/founding_sold.txt" +let real_sold: Int = load_founding_count(sold_file, stripe_key) + +// Generate all page HTML using the real founding count. +let page_html: String = page(real_sold, FOUNDING_TOTAL) +fs_write(html_path, page_html) + +// Generate about page HTML. +let about_html_path: String = src_dir + "/about.html" +let about_html: String = page_open() + about_page() + page_close() +fs_write(about_html_path, about_html) + +// Generate terms pages HTML. +let terms_html_path: String = src_dir + "/terms.html" +let ent_terms_html_path: String = src_dir + "/enterprise-terms.html" +fs_write(terms_html_path, terms_page()) +fs_write(ent_terms_html_path, enterprise_terms_page()) + +// Register with El HTTP runtime. +state_set("__html_file__", html_path) +state_set("__about_html_file__", about_html_path) +state_set("__terms_html_file__", terms_html_path) +state_set("__enterprise_terms_html_file__", ent_terms_html_path) +state_set("__src_dir__", src_dir) +state_set("__stripe_secret_key__", stripe_key) +state_set("__stripe_publishable_key__", stripe_pub_key) +state_set("__stripe_price_founding__", stripe_price_founding) +state_set("__stripe_price_professional__", stripe_price_professional) +state_set("__license_api_url__", license_api_url) +state_set("__resend_api_key__", resend_api_key) +state_set("__origin__", neuron_origin) +state_set("__founding_sold_file__", sold_file) +state_set("__founding_sold__", int_to_str(real_sold)) +state_set("__founding_total__", int_to_str(FOUNDING_TOTAL)) +persist_founding_count(real_sold) + +println(color_bold("Neuron landing") + " - " + neuron_origin) +println(" HTML generated by El → " + html_path) +println(" About generated by El → " + about_html_path) +println(" Terms generated by El → " + terms_html_path) +println(" Ent. Terms generated by El → " + ent_terms_html_path) +println(" Assets → " + src_dir + "/assets/") +println("") +println(" Routes:") +println(" GET / → El-generated landing page") +println(" GET /about → El-generated about page") +println(" GET /legal/terms → Consumer terms of service") +println(" GET /legal/enterprise-terms → Enterprise agreement") +println(" GET /api/health → health check") +println(" GET /api/founding-count → founding counter JSON") +println(" POST /api/checkout → Stripe checkout session") +println(" POST /api/webhooks/stripe → Stripe webhook") +println(" GET /marketplace/success → post-purchase success page") +println(" GET /assets/* → static files") +println(" GET /brand/* → brand files") +println("") + +http_serve(3001) diff --git a/build/landing.c b/build/landing.c new file mode 100644 index 0000000..e69de29 diff --git a/build/server.c b/build/server.c new file mode 100644 index 0000000..efee304 --- /dev/null +++ b/build/server.c @@ -0,0 +1,152 @@ +#include +#include +#include "el_runtime.h" + +el_val_t strip_query(el_val_t path); +el_val_t root_dir(void); +el_val_t parse_port(void); +el_val_t content_type_for(el_val_t path); +el_val_t route_health(void); +el_val_t route_founding_count(void); +el_val_t route_index(void); +el_val_t route_static(el_val_t path); +el_val_t route_brand(el_val_t path); +el_val_t err_404(el_val_t path); +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body); + +el_val_t strip_query(el_val_t path) { + el_val_t q = str_index_of(path, EL_STR("?")); + if (q < 0) { + return path; + } + return str_slice(path, 0, q); + return 0; +} + +el_val_t root_dir(void) { + el_val_t r = env(EL_STR("LANDING_ROOT")); + if (str_eq(r, EL_STR(""))) { + r = EL_STR("./src"); + } + return r; + return 0; +} + +el_val_t parse_port(void) { + el_val_t p = env(EL_STR("PORT")); + if (str_eq(p, EL_STR(""))) { + p = EL_STR("8080"); + } + return str_to_int(p); + return 0; +} + +el_val_t content_type_for(el_val_t path) { + if (str_ends_with(path, EL_STR(".html"))) { + return EL_STR("text/html; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".css"))) { + return EL_STR("text/css; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".js"))) { + return EL_STR("application/javascript; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".json"))) { + return EL_STR("application/json; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".png"))) { + return EL_STR("image/png"); + } + if (str_ends_with(path, EL_STR(".jpg"))) { + return EL_STR("image/jpeg"); + } + if (str_ends_with(path, EL_STR(".jpeg"))) { + return EL_STR("image/jpeg"); + } + if (str_ends_with(path, EL_STR(".svg"))) { + return EL_STR("image/svg+xml"); + } + if (str_ends_with(path, EL_STR(".ico"))) { + return EL_STR("image/x-icon"); + } + if (str_ends_with(path, EL_STR(".webp"))) { + return EL_STR("image/webp"); + } + if (str_ends_with(path, EL_STR(".woff2"))) { + return EL_STR("font/woff2"); + } + if (str_ends_with(path, EL_STR(".woff"))) { + return EL_STR("font/woff"); + } + return EL_STR("application/octet-stream"); + return 0; +} + +el_val_t route_health(void) { + return EL_STR("{\"status\":\"ok\",\"engine\":\"el-landing\"}"); + return 0; +} + +el_val_t route_founding_count(void) { + el_val_t sold = 47; + el_val_t total = 1000; + el_val_t remaining = (total - sold); + return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"sold\":"), int_to_str(sold)), EL_STR(",\"total\":")), int_to_str(total)), EL_STR(",\"remaining\":")), int_to_str(remaining)), EL_STR("}")); + return 0; +} + +el_val_t route_index(void) { + return fs_read(el_str_concat(root_dir(), EL_STR("/index.html"))); + return 0; +} + +el_val_t route_static(el_val_t path) { + return fs_read(el_str_concat(root_dir(), path)); + return 0; +} + +el_val_t route_brand(el_val_t path) { + el_val_t after = str_slice(path, 6, str_len(path)); + return fs_read(el_str_concat(el_str_concat(root_dir(), EL_STR("/assets/brand")), after)); + return 0; +} + +el_val_t err_404(el_val_t path) { + return el_str_concat(el_str_concat(EL_STR("{\"error\":\"not found\",\"path\":\""), path), EL_STR("\"}")); + return 0; +} + +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) { + el_val_t clean = strip_query(path); + if (!str_eq(method, EL_STR("GET"))) { + return EL_STR("{\"error\":\"method not allowed\"}"); + } + if (str_eq(clean, EL_STR("/"))) { + return route_index(); + } + if (str_eq(clean, EL_STR("/health"))) { + return route_health(); + } + if (str_eq(clean, EL_STR("/api/founding-count"))) { + return route_founding_count(); + } + if (str_starts_with(clean, EL_STR("/assets/"))) { + return route_static(clean); + } + if (str_starts_with(clean, EL_STR("/brand/"))) { + return route_brand(clean); + } + return err_404(clean); + return 0; +} + +int main(int argc, char** argv) { + el_runtime_init_args(argc, argv); + el_val_t port = parse_port(); + println(el_str_concat(EL_STR("[landing] root="), root_dir())); + println(el_str_concat(EL_STR("[landing] listening on "), int_to_str(port))); + http_set_handler(EL_STR("handle_request")); + http_serve(port, EL_STR("handle_request")); + return 0; +} + diff --git a/runtime/el_runtime.c b/runtime/el_runtime.c new file mode 100644 index 0000000..74ea701 --- /dev/null +++ b/runtime/el_runtime.c @@ -0,0 +1,6166 @@ +/* + * 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 + +/* ── 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; +} + +/* 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 ──────────────────────────────────────────────────────────────────── */ + +void println(el_val_t s) { + const char* str = EL_CSTR(s); + if (str) puts(str); + else puts(""); +} + +void print(el_val_t s) { + const char* str = EL_CSTR(s); + if (str) fputs(str, stdout); +} + +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)); +} + +/* ── 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); +} + +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. + */ + +/* ── 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; +} + +/* 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); +} + +/* 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; +} + +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; +} + +/* ── 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); +} + +void 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); +} + +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; +} + +/* 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. */ +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); + + 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 */ + + 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 + && 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; + el_request_start(); /* begin per-request arena */ + if (h) { + el_val_t r = h(EL_STR(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 */ + http_send_response(fd, response); + 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; +} + +void 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; } + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { perror("socket"); return; } + int yes = 1; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)p); + if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("bind"); close(sock); return; + } + if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; } + fprintf(stderr, "[http] listening on 0.0.0.0:%d\n", p); + while (1) { + struct sockaddr_in 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); +} + +/* ── 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); +} + +void 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); +} + +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; + 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(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 */ + http_send_response(fd, response); + 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; +} + +void 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; + } + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { perror("socket"); return; } + int yes = 1; + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_ANY); + addr.sin_port = htons((uint16_t)p); + if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + perror("bind"); close(sock); return; + } + if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; } + fprintf(stderr, "[http v2] listening on 0.0.0.0:%d\n", p); + while (1) { + struct sockaddr_in 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); +} + +/* 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); + 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; +} + +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_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); +} + +/* ── JSON ────────────────────────────────────────────────────────────────── */ + +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("")); + size_t klen = strlen(key); + /* Use a stack buffer for the pattern to avoid arena double-free. + * Keys in El maps are typically short; 512 bytes is a safe upper bound. */ + 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": */ + while (*p == ' ' || *p == '\t' || *p == '\n') p++; + if (*p == '"') { + p++; + const char* start = p; + while (*p && !(*p == '"' && *(p-1) != '\\')) p++; + size_t len = (size_t)(p - start); + char* out = el_strbuf(len); + memcpy(out, start, len); + out[len] = '\0'; + return el_wrap_str(out); + } + const char* start = p; + while (*p && *p != ',' && *p != '}' && *p != ']' && *p != '\n') p++; + size_t len = (size_t)(p - start); + char* out = el_strbuf(len); + memcpy(out, start, len); + out[len] = '\0'; + return el_wrap_str(out); +} + +/* ── 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 */ + uintptr_t p = (uintptr_t)v; + /* Small integers (positive and negative) are not pointers */ + if ((int64_t)v >= -1000000 && (int64_t)v <= 1000000) return 0; + 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 i > 0; /* terminated string */ + 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); + 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); + if (!k) k = ""; + if (!json || !*json) { + /* Build a fresh object */ + JsonBuf b; jb_init(&b); + jb_putc(&b, '{'); + jb_emit_escaped(&b, k); + jb_putc(&b, ':'); + jb_emit_value(&b, value); + 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_emit_value(&b, value); + 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_emit_value(&b, value); + /* 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); +} + +/* ── 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 || 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); + el_val_t m = el_map_new(0); + m = el_map_set(m, EL_STR(el_strdup("year")), (el_val_t)(tm.tm_year + 1900)); + m = el_map_set(m, EL_STR(el_strdup("month")), (el_val_t)(tm.tm_mon + 1)); + m = el_map_set(m, EL_STR(el_strdup("day")), (el_val_t)tm.tm_mday); + m = el_map_set(m, EL_STR(el_strdup("hour")), (el_val_t)tm.tm_hour); + m = el_map_set(m, EL_STR(el_strdup("minute")), (el_val_t)tm.tm_min); + m = el_map_set(m, EL_STR(el_strdup("second")), (el_val_t)tm.tm_sec); + m = el_map_set(m, EL_STR(el_strdup("ms")), (el_val_t)msec); + return m; +} + +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; +} + +/* ── 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; + +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 = ""; + StateEntry* e = state_find(k); + if (e) { + free(e->value); + /* use persist allocator — state values must survive arena teardown */ + e->value = el_strdup_persist(v); + return 1; + } + if (_state_count >= _state_cap) { + size_t nc = _state_cap == 0 ? 16 : _state_cap * 2; + _state_entries = realloc(_state_entries, nc * sizeof(StateEntry)); + if (!_state_entries) { fputs("el_runtime: out of memory\n", stderr); exit(1); } + _state_cap = nc; + } + _state_entries[_state_count].key = el_strdup_persist(k); + _state_entries[_state_count].value = el_strdup_persist(v); + _state_count++; + 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("")); + StateEntry* e = state_find(k); + return el_wrap_str(el_strdup(e ? e->value : "")); +} + +el_val_t state_del(el_val_t key) { + const char* k = EL_CSTR(key); + if (!k) return 0; + 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--; + return 1; + } + } + return 1; +} + +el_val_t state_keys(void) { + el_val_t lst = el_list_empty(); + for (size_t i = 0; i < _state_count; i++) { + lst = el_list_append(lst, el_wrap_str(el_strdup(_state_entries[i].key))); + } + return lst; +} + +/* ── Float formatting ────────────────────────────────────────────────────── */ + +el_val_t float_to_str(el_val_t f) { + char buf[64]; + snprintf(buf, sizeof(buf), "%g", el_to_float(f)); + 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 template, el_val_t data) { + const char* tpl = EL_CSTR(template); + 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); } + +/* ── 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 ─────────────────────────────────────────────────────────────── */ + +void exit_program(el_val_t code) { + exit((int)code); +} + +/* 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(); +} + +/* ── 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. + * + * Activation algorithm (engram_activate): + * 1. Find seed nodes whose content/label/tags contain query (case-insens). + * 2. BFS up to `depth` hops along outgoing+incoming edges from each seed. + * 3. activation = seed.salience * product(edge_weights) * 0.7^hops + * 4. If reached by multiple paths, take max activation. + * 5. epistemic_confidence = activation * node.confidence + * 6. Filter: epistemic_confidence >= 0.2 + * 7. Sort descending by activation_strength. + */ + +typedef struct EngramNode { + char* id; + char* content; + char* node_type; + char* label; + char* tier; + char* tags; + char* metadata; + double salience; + double importance; + double confidence; + int64_t activation_count; + int64_t last_activated; + int64_t created_at; + int64_t updated_at; +} 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; +} EngramEdge; + +typedef struct EngramStore { + EngramNode* nodes; + int64_t node_count; + int64_t node_capacity; + EngramEdge* edges; + int64_t edge_count; + int64_t edge_capacity; +} EngramStore; + +static EngramStore* engram_global = NULL; + +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)); + return engram_global; +} + +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("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); + 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->activation_count = 0; + int64_t now = engram_now_ms(); + n->last_activated = now; + n->created_at = now; + n->updated_at = now; + 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; + int64_t now = engram_now_ms(); + n->last_activated = now; + n->created_at = now; + n->updated_at = now; + g->node_count++; + return el_wrap_str(el_strdup(n->id)); +} + +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]; + 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; + for (int64_t i = 0; i < g->node_count; i++) idx[i] = i; + engram_sort_indices_by_salience(idx, g->node_count, g->nodes); + int64_t end = off + lim; + if (end > g->node_count) end = g->node_count; + 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; + 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); + 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; +} + +/* Spreading activation. Returns ElList of {node, activation_strength, hops}. */ +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; + + /* Per-node activation tracking. */ + double* best_activation = 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_activation || !best_hops || !reached) { + free(best_activation); free(best_hops); free(reached); return out; + } + + /* Find seeds */ + typedef struct { int64_t idx; double act; } SeedEntry; + SeedEntry* seeds = malloc((size_t)g->node_count * sizeof(SeedEntry)); + int64_t seed_count = 0; + if (!seeds) { + free(best_activation); 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)) { + seeds[seed_count].idx = i; + seeds[seed_count].act = n->salience; + seed_count++; + best_activation[i] = n->salience; + best_hops[i] = 0; + reached[i] = 1; + } + } + /* BFS from each seed. We'll maintain a queue of (node_idx, depth, act). */ + 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_activation); 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 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; + double new_act = f.act * e->weight * DECAY; + int64_t new_hops = f.hops + 1; + if (!reached[oi] || new_act > best_activation[oi]) { + best_activation[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++; + } + } + } + } + + /* Collect, filter by epistemic_confidence >= 0.2, sort desc by activation. */ + typedef struct { int64_t idx; double act; double epist; int64_t hops; } Result; + Result* results = malloc((size_t)g->node_count * sizeof(Result)); + int64_t rcount = 0; + if (!results) { + free(best_activation); free(best_hops); free(reached); free(seeds); free(fr); + return out; + } + for (int64_t i = 0; i < g->node_count; i++) { + if (!reached[i]) continue; + double epist = best_activation[i] * g->nodes[i].confidence; + if (epist < 0.2) continue; + results[rcount].idx = i; + results[rcount].act = best_activation[i]; + results[rcount].epist = epist; + results[rcount].hops = best_hops[i]; + rcount++; + } + /* Insertion sort by act desc. */ + for (int64_t i = 1; i < rcount; i++) { + Result key = results[i]; + int64_t j = i - 1; + while (j >= 0 && results[j].act < key.act) { + 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].act)); + 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); + out = el_list_append(out, entry); + } + free(best_activation); free(best_hops); free(reached); + free(seeds); free(fr); 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[64]; + 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), ",\"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); + 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); + 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]); + } + 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->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"); + 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"); + 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); } + } + } + } + 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]; + 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); } + for (int64_t i = 0; i < g->node_count; i++) idx[i] = i; + engram_sort_indices_by_salience(idx, g->node_count, g->nodes); + int64_t end = off + lim; + if (end > g->node_count) end = g->node_count; + 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); + } + frontier[fc] = el_strdup(sid); frontier_h[fc] = 0; fc++; + visited[vc++] = el_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++] = el_strdup(peer); + if (fc < 1024 && h + 1 < depth) { frontier[fc] = el_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 the existing engram_activate to get the ElList of result maps, + * then walk that list and serialize each entry into JSON manually. + * We have the raw nodes via engram_find_node, so we can re-emit + * directly without trusting json_stringify on the ElMap. */ + 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++) { + ElMap* entry = (ElMap*)(uintptr_t)arr->elems[i]; + if (!entry) continue; + /* The entry map has keys: "node" (ElMap), "activation_strength" + * (Float bit-pattern), "hops" (Int). Read them from the map + * directly using el_map_get with EL_STR keys. */ + 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 hops_v = el_map_get(arr->elems[i], EL_STR("hops")); + /* Look up the underlying EngramNode by id field of the map */ + 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[64]; + snprintf(tmp, sizeof(tmp), ",\"activation_strength\":%g", el_to_float(strength_v)); + jb_puts(&b, tmp); + snprintf(tmp, sizeof(tmp), ",\"hops\":%lld}", (long long)(int64_t)hops_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}", + (long long)g->node_count, (long long)g->edge_count); + return el_wrap_str(el_strdup(buf)); +} + +/* ── 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; + 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; + 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; + 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; +} + +/* ── 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. + */ + +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; +} + +/* Make an Anthropic /v1/messages request with the given JSON body. Returns + * the assistant's first text content as an owned string, or a JSON error + * fragment on transport failure. */ +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* line = malloc(n); + snprintf(line, n, "x-api-key: %s", api_key); + h = curl_slist_append(h, line); + free(line); + } + { + size_t n = strlen(LLM_VERSION) + 32; + char* line = malloc(n); + snprintf(line, n, "anthropic-version: %s", LLM_VERSION); + h = curl_slist_append(h, line); + free(line); + } + 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* m = llm_resolve_model(EL_CSTR(model)); + const char* u = EL_CSTR(prompt); + if (!u) u = ""; + 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"); + jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":\""); + jb_puts(&b, esc_user); + jb_puts(&b, "\"}]}"); + free(esc_user); + el_val_t resp = llm_request(b.buf); + free(b.buf); + return llm_extract_text(resp); +} + +el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) { + const char* m = llm_resolve_model(EL_CSTR(model)); + const char* s = EL_CSTR(system_prompt); if (!s) s = ""; + const char* u = EL_CSTR(user_prompt); if (!u) u = ""; + 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\":\""); + jb_puts(&b, esc_user); + jb_puts(&b, "\"}]}"); + free(esc_sys); free(esc_user); + el_val_t resp = llm_request(b.buf); + free(b.buf); + return llm_extract_text(resp); +} + +/* ── 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; +} + +/* ── 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 */ diff --git a/runtime/el_runtime.h b/runtime/el_runtime.h new file mode 100644 index 0000000..693c210 --- /dev/null +++ b/runtime/el_runtime.h @@ -0,0 +1,507 @@ +/* + * 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 + * + * 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) + +/* 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 ──────────────────────────────────────────────────────────────────── */ + +void println(el_val_t s); +void 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 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); + +/* ── 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_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); +void http_serve(el_val_t port, el_val_t handler); +void 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. */ +void http_serve_v2(el_val_t port, el_val_t handler); +void 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); + +/* 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 */ + +/* ── 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_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); + +/* ── 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); + +/* ── 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); + +/* ── 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 template, el_val_t data); +el_val_t str_lower(el_val_t s); +el_val_t str_upper(el_val_t s); + +/* ── 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 ─────────────────────────────────────────────────────────────── */ + +void exit_program(el_val_t code); +el_val_t getpid_now(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); +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); + +/* 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_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); + +/* ── 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); + +/* ── 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 */ + +#ifdef __cplusplus +} +#endif diff --git a/server.c b/server.c new file mode 100644 index 0000000..f97722f --- /dev/null +++ b/server.c @@ -0,0 +1,163 @@ +#include +#include +#include "el_runtime.h" + +el_val_t strip_query(el_val_t path); +el_val_t root_dir(void); +el_val_t parse_port(void); +el_val_t content_type_for(el_val_t path); +el_val_t route_health(void); +el_val_t route_founding_count(void); +el_val_t route_index(void); +el_val_t route_static(el_val_t path); +el_val_t route_brand(el_val_t path); +el_val_t err_404(el_val_t path); +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body); + +el_val_t port; + +el_val_t strip_query(el_val_t path) { + el_val_t q = str_index_of(path, EL_STR("?")); + if (q < 0) { + return path; + } + return str_slice(path, 0, q); + return 0; +} + +el_val_t root_dir(void) { + el_val_t r = env(EL_STR("LANDING_ROOT")); + if (str_eq(r, EL_STR(""))) { + r = EL_STR("./src"); + } + return r; + return 0; +} + +el_val_t parse_port(void) { + el_val_t p = env(EL_STR("PORT")); + if (str_eq(p, EL_STR(""))) { + p = EL_STR("8080"); + } + return str_to_int(p); + return 0; +} + +el_val_t content_type_for(el_val_t path) { + if (str_ends_with(path, EL_STR(".html"))) { + return EL_STR("text/html; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".css"))) { + return EL_STR("text/css; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".js"))) { + return EL_STR("application/javascript; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".json"))) { + return EL_STR("application/json; charset=utf-8"); + } + if (str_ends_with(path, EL_STR(".png"))) { + return EL_STR("image/png"); + } + if (str_ends_with(path, EL_STR(".jpg"))) { + return EL_STR("image/jpeg"); + } + if (str_ends_with(path, EL_STR(".jpeg"))) { + return EL_STR("image/jpeg"); + } + if (str_ends_with(path, EL_STR(".svg"))) { + return EL_STR("image/svg+xml"); + } + if (str_ends_with(path, EL_STR(".ico"))) { + return EL_STR("image/x-icon"); + } + if (str_ends_with(path, EL_STR(".webp"))) { + return EL_STR("image/webp"); + } + if (str_ends_with(path, EL_STR(".woff2"))) { + return EL_STR("font/woff2"); + } + if (str_ends_with(path, EL_STR(".woff"))) { + return EL_STR("font/woff"); + } + return EL_STR("application/octet-stream"); + return 0; +} + +el_val_t route_health(void) { + return EL_STR("{\"status\":\"ok\",\"engine\":\"el-landing\"}"); + return 0; +} + +el_val_t route_founding_count(void) { + el_val_t sold = 47; + el_val_t total = 1000; + el_val_t remaining = (total - sold); + return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"sold\":"), int_to_str(sold)), EL_STR(",\"total\":")), int_to_str(total)), EL_STR(",\"remaining\":")), int_to_str(remaining)), EL_STR("}")); + return 0; +} + +el_val_t route_index(void) { + return fs_read(el_str_concat(root_dir(), EL_STR("/index.html"))); + return 0; +} + +el_val_t route_static(el_val_t path) { + return fs_read(el_str_concat(root_dir(), path)); + return 0; +} + +el_val_t route_brand(el_val_t path) { + el_val_t after = str_slice(path, 6, str_len(path)); + return fs_read(el_str_concat(el_str_concat(root_dir(), EL_STR("/assets/brand")), after)); + return 0; +} + +el_val_t err_404(el_val_t path) { + return el_str_concat(el_str_concat(EL_STR("{\"error\":\"not found\",\"path\":\""), path), EL_STR("\"}")); + return 0; +} + +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) { + el_val_t clean = strip_query(path); + if (!str_eq(method, EL_STR("GET"))) { + return EL_STR("{\"error\":\"method not allowed\"}"); + } + if (str_eq(clean, EL_STR("/"))) { + return route_index(); + } + if (str_eq(clean, EL_STR("/health"))) { + return route_health(); + } + if (str_eq(clean, EL_STR("/api/founding-count"))) { + return route_founding_count(); + } + if (str_starts_with(clean, EL_STR("/assets/"))) { + return route_static(clean); + } + if (str_starts_with(clean, EL_STR("/brand/"))) { + return route_brand(clean); + } + if (str_eq(clean, EL_STR("/legal/enterprise-terms"))) { + return fs_read(el_str_concat(root_dir(), EL_STR("/enterprise-terms.html"))); + } + if (str_eq(clean, EL_STR("/legal/terms"))) { + return fs_read(el_str_concat(root_dir(), EL_STR("/terms.html"))); + } + if (str_eq(clean, EL_STR("/about"))) { + return fs_read(el_str_concat(root_dir(), EL_STR("/about.html"))); + } + return err_404(clean); + return 0; +} + +int main(int argc, char** argv) { + el_runtime_init_args(argc, argv); + port = parse_port(); + println(el_str_concat(EL_STR("[landing] root="), root_dir())); + println(el_str_concat(EL_STR("[landing] listening on "), int_to_str(port))); + http_set_handler(EL_STR("handle_request")); + http_serve(port, EL_STR("handle_request")); + return 0; +} + diff --git a/server.el b/server.el index 4a4a3ad..dc41d83 100644 --- a/server.el +++ b/server.el @@ -112,6 +112,15 @@ fn handle_request(method: String, path: String, body: String) -> String { if str_starts_with(clean, "/brand/") { return route_brand(clean) } + if str_eq(clean, "/legal/enterprise-terms") { + return fs_read(root_dir() + "/enterprise-terms.html") + } + if str_eq(clean, "/legal/terms") { + return fs_read(root_dir() + "/terms.html") + } + if str_eq(clean, "/about") { + return fs_read(root_dir() + "/about.html") + } err_404(clean) } diff --git a/src/assets/brand/neuron-icon-32.png b/src/assets/brand/neuron-icon-32.png new file mode 100644 index 0000000..18474c6 Binary files /dev/null and b/src/assets/brand/neuron-icon-32.png differ diff --git a/src/assets/logos/chatgpt.ico b/src/assets/logos/chatgpt.ico new file mode 100644 index 0000000..7737126 --- /dev/null +++ b/src/assets/logos/chatgpt.ico @@ -0,0 +1,26 @@ + + + + + +
    + +
    +
    + + diff --git a/src/assets/logos/chatgpt.png b/src/assets/logos/chatgpt.png new file mode 100644 index 0000000..c602f7b --- /dev/null +++ b/src/assets/logos/chatgpt.png @@ -0,0 +1,27 @@ + + + + + + +
    + +
    +
    + + diff --git a/src/assets/logos/chatgpt.svg b/src/assets/logos/chatgpt.svg new file mode 100644 index 0000000..97f3029 --- /dev/null +++ b/src/assets/logos/chatgpt.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/logos/claude.ico b/src/assets/logos/claude.ico new file mode 100644 index 0000000..ec917df Binary files /dev/null and b/src/assets/logos/claude.ico differ diff --git a/src/assets/logos/claude.png b/src/assets/logos/claude.png new file mode 100644 index 0000000..0771e62 Binary files /dev/null and b/src/assets/logos/claude.png differ diff --git a/src/assets/logos/claude_icon.png b/src/assets/logos/claude_icon.png new file mode 100644 index 0000000..aba0a9e Binary files /dev/null and b/src/assets/logos/claude_icon.png differ diff --git a/src/assets/logos/copilot.ico b/src/assets/logos/copilot.ico new file mode 100644 index 0000000..bfe873e Binary files /dev/null and b/src/assets/logos/copilot.ico differ diff --git a/src/assets/logos/copilot.png b/src/assets/logos/copilot.png new file mode 100644 index 0000000..5830e7a Binary files /dev/null and b/src/assets/logos/copilot.png differ diff --git a/src/assets/logos/copilot_icon.png b/src/assets/logos/copilot_icon.png new file mode 100644 index 0000000..bc72cc5 Binary files /dev/null and b/src/assets/logos/copilot_icon.png differ diff --git a/src/assets/logos/gemini.png b/src/assets/logos/gemini.png new file mode 100644 index 0000000..330ef58 Binary files /dev/null and b/src/assets/logos/gemini.png differ diff --git a/src/assets/logos/gemini.svg b/src/assets/logos/gemini.svg new file mode 100644 index 0000000..4545c83 --- /dev/null +++ b/src/assets/logos/gemini.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/assets/neuron-icon.png b/src/assets/neuron-icon.png index 3d4311c..18474c6 100644 Binary files a/src/assets/neuron-icon.png and b/src/assets/neuron-icon.png differ diff --git a/src/checkout.el b/src/checkout.el index f063189..e150659 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -13,15 +13,16 @@ fn checkout_page(plan: String, pub_key: String) -> String { let is_founding: Bool = str_eq(plan, "founding") + let is_free: Bool = str_eq(plan, "free") - let plan_name: String = if is_founding { "Founding Member" } else { "Professional" } - let plan_price: String = if is_founding { "$199" } else { "$19 / month" } + let plan_name: String = if is_founding { "Founding Member" } else { if is_free { "Free" } else { "Professional" } } + let plan_price: String = if is_founding { "$199" } else { if is_free { "$0" } else { "$19 / month" } } let plan_desc: String = if is_founding { "Pay once. Neuron inference forever. No subscription, ever." } else { "Full access. Neuron inference - cheaper than what you're paying now." } - let plan_cadence: String = if is_founding { "one-time" } else { "billed monthly" } + let plan_cadence: String = if is_founding { "one-time" } else { if is_free { "forever" } else { "billed monthly" } } let features_html: String = if is_founding { "
  • Neuron inference - priced below competitors, forever
  • @@ -78,9 +79,52 @@ fn checkout_page(plan: String, pub_key: String) -> String {

    - +
    -

    Payment

    + + +
    +

    Sign in to continue

    +

    Create your account or sign in. Your purchase will be linked to this account.

    + +
    + + + + +
    + +
    + +
    + or create an account +
    + +
    + + + +

    Already have an account? Sign in

    +
    +
    + + +
    +
    +

    Payment

    @@ -120,11 +164,14 @@ fn checkout_page(plan: String, pub_key: String) -> String {

    +
    + + @@ -280,8 +327,245 @@ fn checkout_page(plan: String, pub_key: String) -> String { gap: .4rem; margin: 0; } +.checkout-auth-hint { + font-family: var(--body); + font-weight: 300; + font-size: .875rem; + color: var(--t2); + line-height: 1.6; + margin: 0 0 1.5rem; +} +.checkout-social-btns { + display: flex; + flex-direction: column; + gap: .625rem; + margin-bottom: 1.5rem; +} +.checkout-social-btn { + display: flex; + align-items: center; + gap: .75rem; + width: 100%; + padding: .75rem 1.25rem; + background: #fff; + border: 1px solid rgba(0,82,160,.22); + font-family: var(--body); + font-size: .9rem; + font-weight: 400; + color: var(--t1); + cursor: pointer; + transition: border-color .2s, background .2s; + text-align: left; + border-radius: 0; +} +.checkout-social-btn:hover { border-color: rgba(0,82,160,.5); background: rgba(0,82,160,.03); } +.checkout-social-btn:disabled { opacity: .5; cursor: not-allowed; } +.checkout-auth-divider { + display: flex; + align-items: center; + gap: .75rem; + margin: 1.25rem 0 1rem; + color: var(--t3); + font-family: var(--body); + font-size: .8125rem; +} +.checkout-auth-divider::before, +.checkout-auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgba(0,82,160,.15); +} +.checkout-skip-btn { + background: none; + border: none; + font-family: var(--body); + font-size: .8125rem; + color: var(--t3); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 3px; + padding: 0; +} +.checkout-skip-btn:hover { color: var(--navy); } +.checkout-auth-badge { + display: flex; + align-items: center; + gap: .6rem; + padding: .6rem .875rem; + background: rgba(0,82,160,.05); + border: 1px solid rgba(0,82,160,.18); + font-family: var(--body); + font-size: .8125rem; + color: var(--t2); +} +.checkout-auth-badge strong { color: var(--navy); font-weight: 500; } + + + + + + + + + + + + + + + + + + + + + + + + + " @@ -1498,6 +1812,108 @@ fn page_close() -> String { })(); + + +
    + +
    + +
    +
    +
    + \"Neuron\" +
    + Neuron +
    Live Demo
    +
    +
    + +
    +
    +
    + + +
    +
    + + " diff --git a/src/terms.el b/src/terms.el index 883f39e..5f176ef 100644 --- a/src/terms.el +++ b/src/terms.el @@ -35,6 +35,7 @@ fn terms_body() -> String {

    What You Can Do With It

    You can install and use Neuron on devices you own or control. That's the license. Personal, non-transferable.

    +

    Each plan includes two devices. Additional devices are available at a small extra cost. We're not greedy about it — two covers most people, and if you need more, you can add them.

    The free tier doesn't expire. Paid licenses are what they say they are. Founding Member licenses are perpetual - you bought it, you own it. I won't change that.

    You can't resell it, sublicense it, or redistribute it without asking first. If you want to do something creative with it, ask. The answer might be yes.