diff --git a/Dockerfile.stage b/Dockerfile.stage new file mode 100644 index 0000000..0340cc7 --- /dev/null +++ b/Dockerfile.stage @@ -0,0 +1,83 @@ +# Dockerfile.stage — Stage build: landing server + soul-demo in one image. +# +# Both processes run in the same container: +# - neuron-web on port 8080 (landing page server) +# - soul-demo on port 7772 (demo chat, localhost only) +# +# Both binaries are compiled from C inside Docker for linux/amd64. +# The engram snapshot is baked in so the soul has memory from first boot. + +# ── Stage 1: compile both binaries ──────────────────────────────────────────── +FROM debian:bookworm-slim AS builder + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libcurl4-openssl-dev \ + libssl-dev \ + python3 \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +# El runtime (shared by both binaries) +COPY runtime/el_runtime.c runtime/el_runtime.h ./ + +# ── Build neuron-web ────────────────────────────────────────────────────────── +COPY dist/web_stubs.c ./ +COPY dist/bootstrap.py ./ +COPY dist/main-combined.el ./ + +RUN python3 bootstrap.py main-combined.el > main.c && \ + sed -i \ + 's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);\nel_val_t supabase_get(el_val_t project_url, el_val_t service_key, el_val_t table_and_query);|' \ + main.c && \ + cc -O2 -rdynamic \ + -o neuron-web \ + main.c web_stubs.c el_runtime.c \ + -lcurl -lpthread -ldl -lm + +# ── Build soul-demo ─────────────────────────────────────────────────────────── +COPY dist/soul-demo.c ./ +COPY dist/vessel_stubs.c ./ + +RUN cc -O2 -rdynamic \ + -o soul-demo \ + soul-demo.c vessel_stubs.c el_runtime.c \ + -lcurl -lpthread -ldl -lm + +# ── Stage 2: runtime image ──────────────────────────────────────────────────── +FROM debian:bookworm-slim + +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 \ + && mkdir -p /srv/landing/assets /srv/landing/shares \ + && mkdir -p /srv/soul/engram-demo \ + && chown -R landing:landing /srv/landing /srv/soul + +COPY --from=builder /build/neuron-web /usr/local/bin/neuron-web +COPY --from=builder /build/soul-demo /usr/local/bin/soul-demo + +# Engram snapshot — baked in so soul has memory from cold start +COPY dist/engram-snapshot.json /srv/soul/engram-demo/snapshot.json + +COPY src/assets /srv/landing/assets + +COPY dist/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENV LANDING_ROOT=/srv/landing +ENV PORT=8080 +ENV NEURON_HOME=/srv/soul/engram-demo +ENV NEURON_PORT=7772 + +USER landing +EXPOSE 8080 + +CMD ["/usr/local/bin/entrypoint.sh"] diff --git a/build-local.sh b/build-local.sh new file mode 100755 index 0000000..84eb8ee --- /dev/null +++ b/build-local.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# build-local.sh — Build and run neuron-web + soul-demo for local development. +# +# Mirrors the container's entrypoint.sh: both processes start together, +# soul-demo in background, neuron-web in foreground. Kill neuron-web (Ctrl-C) +# and the soul dies with it. No separate process manager. No port conflicts. +# +# Usage: +# ./build-local.sh — build only +# ./build-local.sh --run — build and start both servers + +set -euo pipefail +cd "$(dirname "$0")" + +NEURON_DIR="$(cd ../../neuron && pwd)" +EL_HOME="${EL_HOME:-../../foundation/el}" +BOOTSTRAP="${EL_HOME}/bootstrap.py" +RUNTIME_SRC="${EL_HOME}/el-compiler/runtime" + +COMPONENTS=(nav hero pillars how_it_works inference efficiency comparison + environmental enterprise mission local_first pricing marketplace viral + footer styles about founding_badge terms enterprise_terms checkout safety + gallery account) + +echo "==> Combining El sources" +{ + for f in "${COMPONENTS[@]}"; do + [ -f "src/${f}.el" ] && grep -hv '^[[:space:]]*from\|^[[:space:]]*import' "src/${f}.el" && echo "" + done + grep -v '^from\|^import' src/main.el +} > dist/main-combined.el +echo " $(wc -l < dist/main-combined.el) lines" + +echo "==> Bootstrap El → C" +python3 "${BOOTSTRAP}" dist/main-combined.el > dist/main.c + +echo "==> Injecting stubs" +sed -i '' 's|#include "el_runtime.h"|#include "el_runtime.h"\nel_val_t http_get_auth(el_val_t url, el_val_t tok);\nel_val_t http_post_auth(el_val_t url, el_val_t tok, el_val_t body);\nel_val_t cwd(void);\nel_val_t color_bold(el_val_t s);\nel_val_t unix_timestamp(void);\nel_val_t gcs_write(el_val_t bucket, el_val_t object_name, el_val_t content);\nel_val_t gcs_read(el_val_t bucket, el_val_t object_name);\nel_val_t supabase_insert(el_val_t project_url, el_val_t service_key, el_val_t table, el_val_t row_json);|' dist/main.c + +echo "==> Compiling neuron-web" +cc -O2 \ + -I"${RUNTIME_SRC}" \ + -o dist/neuron-web \ + dist/main.c \ + dist/web_stubs.c \ + "${RUNTIME_SRC}/el_runtime.c" \ + -lcurl -lpthread +echo " dist/neuron-web built ok" + +if [[ "${1:-}" == "--run" ]]; then + # Load credentials + if [ -f "$HOME/Secrets/credentials/infrastructure.env" ]; then + set -a; source "$HOME/Secrets/credentials/infrastructure.env"; set +a + fi + + # Get Soma key for Neuron inference + SOMA_KEY="${NEURON_LLM_0_KEY:-$(gcloud secrets versions access latest --secret=soma-operator-key --project=neuron-785695 2>/dev/null || echo '')}" + + # Kill any leftover instances from a previous run + pkill -f "dist/neuron-web" 2>/dev/null || true + pkill -f "neuron/dist/soul-demo" 2>/dev/null || true + sleep 0.5 + + echo "==> Starting soul-demo on :7772" + NEURON_LLM_0_URL="https://soma-prod-us-r4tfklscwq-uc.a.run.app/v1/chat/completions" \ + NEURON_LLM_0_KEY="$SOMA_KEY" \ + NEURON_LLM_0_FORMAT="openai" \ + NEURON_LLM_0_MODEL="neuron" \ + NEURON_LLM_1_KEY="${ANTHROPIC_API_KEY:-}" \ + NEURON_LLM_1_FORMAT="anthropic" \ + NEURON_HOME="$HOME/.neuron/engram-demo" \ + NEURON_PORT=7772 \ + "${NEURON_DIR}/dist/soul-demo" & + SOUL_PID=$! + + echo " soul-demo pid=$SOUL_PID — waiting for init..." + sleep 5 + + echo "==> Starting neuron-web on :3001" + # Trap exit to also kill the soul + trap "echo '==> Stopping...'; kill $SOUL_PID 2>/dev/null; exit 0" INT TERM + + NEURON_LLM_0_URL="https://soma-prod-us-r4tfklscwq-uc.a.run.app/v1/chat/completions" \ + NEURON_LLM_0_KEY="$SOMA_KEY" \ + NEURON_LLM_0_FORMAT="openai" \ + NEURON_LLM_0_MODEL="neuron" \ + NEURON_LLM_1_KEY="${ANTHROPIC_API_KEY:-}" \ + NEURON_LLM_1_FORMAT="anthropic" \ + STRIPE_SECRET_KEY="${STRIPE_SECRET_KEY:-}" \ + STRIPE_PUBLISHABLE_KEY="${STRIPE_PUBLISHABLE_KEY:-}" \ + STRIPE_PRICE_FOUNDING="${STRIPE_PRICE_FOUNDING:-}" \ + STRIPE_PRICE_PROFESSIONAL="${STRIPE_PRICE_PROFESSIONAL:-}" \ + STRIPE_WEBHOOK_SECRET="${STRIPE_WEBHOOK_SECRET:-}" \ + SUPABASE_SERVICE_KEY="${SUPABASE_SERVICE_KEY:-}" \ + SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY:-}" \ + PORT=3001 LANDING_ROOT=./src \ + dist/neuron-web + + # neuron-web exited — clean up soul + kill $SOUL_PID 2>/dev/null || true +fi diff --git a/runtime/el_runtime.c b/runtime/el_runtime.c index 74ea701..3f9e040 100644 --- a/runtime/el_runtime.c +++ b/runtime/el_runtime.c @@ -1895,12 +1895,28 @@ el_val_t json_get(el_val_t jsonv, el_val_t keyv) { 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'; + /* Unescape the JSON string value into a clean buffer. */ + size_t cap = strlen(p) + 1; + char* out = el_strbuf(cap); + char* w = out; + while (*p && *p != '"') { + if (*p == '\\' && *(p+1)) { + p++; + switch (*p) { + case '"': *w++ = '"'; break; + case '\\': *w++ = '\\'; break; + case '/': *w++ = '/'; break; + case 'n': *w++ = '\n'; break; + case 'r': *w++ = '\r'; break; + case 't': *w++ = '\t'; break; + default: *w++ = *p; break; + } + } else { + *w++ = *p; + } + p++; + } + *w = '\0'; return el_wrap_str(out); } const char* start = p; @@ -4534,30 +4550,143 @@ static const char* llm_resolve_model(const char* m) { 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"); +/* + * ── Configurable LLM provider chain ────────────────────────────────────────── + * + * Providers are configured via indexed env vars. The runtime tries each in + * order (0, 1, 2, ...) and returns the first successful non-empty response. + * + * Per provider (N = 0, 1, 2, ...): + * NEURON_LLM_N_URL — endpoint URL (base URL; /v1/chat/completions appended + * if format is "openai" and not already in URL) + * NEURON_LLM_N_KEY — API key + * NEURON_LLM_N_FORMAT — "openai" (default) or "anthropic" + * NEURON_LLM_N_MODEL — model name override (optional) + * + * Example — Neuron inference primary, Anthropic fallback: + * NEURON_LLM_0_URL=https://soma.../v1/chat/completions + * NEURON_LLM_0_KEY=svc-key + * NEURON_LLM_0_FORMAT=openai + * NEURON_LLM_0_MODEL=neuron + * NEURON_LLM_1_URL=https://api.anthropic.com/v1/messages + * NEURON_LLM_1_KEY=sk-ant-... + * NEURON_LLM_1_FORMAT=anthropic + * + * If no NEURON_LLM_0_URL is set, falls back to legacy ANTHROPIC_API_KEY. + */ + +#define LLM_MAX_PROVIDERS 16 + +/* forward declarations */ +static el_val_t llm_extract_text(el_val_t resp_val); +static el_val_t llm_extract_text_openai(el_val_t resp_val); + +static el_val_t llm_extract_text_openai(el_val_t resp_val) { + const char* resp = EL_CSTR(resp_val); + if (!resp || !*resp) return el_wrap_str(el_strdup("")); + if (resp[0] == '{' && strstr(resp, "\"error\"")) return el_wrap_str(el_strdup("")); + const char* choices = json_find_key(resp, "choices"); + if (!choices || *choices != '[') return el_wrap_str(el_strdup("")); + choices++; + while (*choices == ' ' || *choices == '\t') choices++; + if (*choices != '{') return el_wrap_str(el_strdup("")); + const char* end = json_skip_value(choices); + size_t n = (size_t)(end - choices); + char* obj = malloc(n + 1); memcpy(obj, choices, n); obj[n] = '\0'; + const char* msg = json_find_key(obj, "message"); + if (!msg || *msg != '{') { free(obj); return el_wrap_str(el_strdup("")); } + const char* msg_end = json_skip_value(msg); + size_t mn = (size_t)(msg_end - msg); + char* msg_obj = malloc(mn + 1); memcpy(msg_obj, msg, mn); msg_obj[mn] = '\0'; + const char* content = json_find_key(msg_obj, "content"); + el_val_t result = el_wrap_str(el_strdup("")); + if (content && *content == '"') { + JsonParser jp = { .p = content, .end = content + strlen(content), .err = 0 }; + char* text = jp_parse_string_raw(&jp); + if (!jp.err && text) result = el_wrap_str(text); } + free(msg_obj); free(obj); + return result; +} + +/* Send a request to one provider. Returns the raw response string. + * format: 0 = openai, 1 = anthropic */ +static el_val_t llm_provider_request(const char* url, const char* key, + int format, const char* model, + const char* system_str, + const char* user_str) { + char* esc_sys = system_str && *system_str ? json_escape_alloc(system_str) : NULL; + char* esc_user = json_escape_alloc(user_str ? user_str : ""); + JsonBuf b; jb_init(&b); struct curl_slist* h = NULL; h = curl_slist_append(h, "Content-Type: application/json"); - { - 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); + + if (format == 0) { /* OpenAI */ + char full_url[1024]; + if (strstr(url, "/chat/completions") || strstr(url, "/messages")) { + snprintf(full_url, sizeof(full_url), "%s", url); + } else { + snprintf(full_url, sizeof(full_url), "%s/v1/chat/completions", url); + } + { size_t n = strlen(key)+24; char* l=malloc(n); snprintf(l,n,"Authorization: Bearer %s",key); h=curl_slist_append(h,l); free(l); } + jb_putc(&b, '{'); + jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : "neuron"); + jb_puts(&b, ",\"max_tokens\":4096,\"messages\":["); + if (esc_sys && *esc_sys) { jb_puts(&b,"{\"role\":\"system\",\"content\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\"},"); } + jb_puts(&b, "{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}"); + el_val_t resp = http_do("POST", full_url, b.buf, h); + curl_slist_free_all(h); free(b.buf); + if (esc_sys) free(esc_sys); free(esc_user); + return llm_extract_text_openai(resp); + } else { /* Anthropic */ + { size_t n = strlen(key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",key); h=curl_slist_append(h,l); free(l); } + { size_t n = strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); } + jb_putc(&b, '{'); + jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : LLM_DEFAULT_MODEL); + jb_puts(&b, ",\"max_tokens\":4096"); + if (esc_sys && *esc_sys) { jb_puts(&b,",\"system\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\""); } + jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}"); + el_val_t resp = http_do("POST", url, b.buf, h); + curl_slist_free_all(h); free(b.buf); + if (esc_sys) free(esc_sys); free(esc_user); + return llm_extract_text(resp); } - { - 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); +} + +static el_val_t llm_chain_call(const char* system_str, const char* user_str) { + char url_key[64], key_key[64], fmt_key[64], model_key[64]; + for (int i = 0; i < LLM_MAX_PROVIDERS; i++) { + snprintf(url_key, sizeof(url_key), "NEURON_LLM_%d_URL", i); + snprintf(key_key, sizeof(key_key), "NEURON_LLM_%d_KEY", i); + snprintf(fmt_key, sizeof(fmt_key), "NEURON_LLM_%d_FORMAT", i); + snprintf(model_key, sizeof(model_key), "NEURON_LLM_%d_MODEL", i); + const char* url = getenv(url_key); + const char* key = getenv(key_key); + if (!url || !*url || !key || !*key) break; /* end of chain */ + const char* fmt_s = getenv(fmt_key); + int fmt = (fmt_s && strcmp(fmt_s, "anthropic") == 0) ? 1 : 0; + const char* model = getenv(model_key); + fprintf(stderr, "[llm] trying provider %d (%s)\n", i, url); + el_val_t result = llm_provider_request(url, key, fmt, model, system_str, user_str); + const char* t = EL_CSTR(result); + if (t && *t && t[0] != '{') return result; /* success */ + fprintf(stderr, "[llm] provider %d failed or empty, trying next\n", i); } + /* Legacy fallback: ANTHROPIC_API_KEY */ + const char* api_key = getenv("ANTHROPIC_API_KEY"); + if (!api_key || !*api_key) return http_error_json("no LLM providers configured"); + fprintf(stderr, "[llm] using legacy ANTHROPIC_API_KEY fallback\n"); + return llm_provider_request(LLM_API_URL, api_key, 1, NULL, system_str, user_str); +} + +/* Legacy llm_request — kept for backward compat with agentic loop internals */ +static el_val_t llm_request(const char* json_body) { + const char* api_key = getenv("ANTHROPIC_API_KEY"); + if (!api_key || !*api_key) return http_error_json("ANTHROPIC_API_KEY not set"); + struct curl_slist* h = NULL; + h = curl_slist_append(h, "Content-Type: application/json"); + { size_t n=strlen(api_key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",api_key); h=curl_slist_append(h,l); free(l); } + { size_t n=strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); } el_val_t resp = http_do("POST", LLM_API_URL, json_body, h); curl_slist_free_all(h); return resp; @@ -4611,45 +4740,14 @@ static el_val_t llm_extract_text(el_val_t resp_val) { } 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); + const char* u = EL_CSTR(prompt); if (!u) u = ""; + return llm_chain_call(NULL, u); } el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) { - const char* 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); + return llm_chain_call(s, u); } /* ── Tool registry for llm_call_agentic ─────────────────────────────────── */ diff --git a/src/about.el b/src/about.el index 8a5e130..eb470c9 100644 --- a/src/about.el +++ b/src/about.el @@ -141,7 +141,7 @@ fn about_page() -> String {
diff --git a/src/account.el b/src/account.el new file mode 100644 index 0000000..b22627a --- /dev/null +++ b/src/account.el @@ -0,0 +1,1273 @@ +// components/account.el - Account dashboard page. +// Client-rendered: El serves the HTML shell, JS handles auth + data. + +from founding_badge import { founding_badge, founding_badge_css } + +fn account_page(supabase_url: String, supabase_anon_key: String) -> String { + return " + + + + +Use the same sign-in method you used when you signed up.
+ ++ New here? Choose a plan to get started +
+ +