diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..059c278 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +*.elc +*.sealed +*.map.json +.el/ +.claude/ diff --git a/bin/el/src/main.rs b/bin/el/src/main.rs index 5327b1d..22432a8 100644 --- a/bin/el/src/main.rs +++ b/bin/el/src/main.rs @@ -2186,6 +2186,201 @@ fn dispatch_builtin( let _ = request.as_reader().read_to_string(&mut body); } + // ── Built-in landing page routes ───────────────────────────── + // These short-circuit before invoking the Engram handle_request. + + let cors_origin = "Access-Control-Allow-Origin: *".parse::().unwrap(); + let content_json = "Content-Type: application/json".parse::().unwrap(); + let content_html = "Content-Type: text/html; charset=utf-8".parse::().unwrap(); + + // OPTIONS — CORS preflight + if method == "OPTIONS" { + let _ = request.respond( + tiny_http::Response::from_string("") + .with_status_code(204) + .with_header(cors_origin) + .with_header("Access-Control-Allow-Methods: GET, POST, OPTIONS".parse::().unwrap()) + .with_header("Access-Control-Allow-Headers: Content-Type".parse::().unwrap()) + ); + continue; + } + + // GET / — serve __html_file__ if set, otherwise dashboard + if method == "GET" && (path == "/" || path == "/index.html") { + let html_path = GLOBAL_STATE.with(|gs| gs.borrow().get("__html_file__").cloned()); + let html = if let Some(p) = html_path { + std::fs::read_to_string(&p) + .unwrap_or_else(|_| include_str!("dashboard.html").to_string()) + } else { + include_str!("dashboard.html").to_string() + }; + let _ = request.respond( + tiny_http::Response::from_string(html) + .with_header(content_html) + ); + continue; + } + + // GET /health + if method == "GET" && path == "/health" { + let _ = request.respond( + tiny_http::Response::from_string(r#"{"status":"ok"}"#) + .with_header(content_json) + ); + continue; + } + + // POST /api/chat — proxy to Neuron runtime (SSE → collect → JSON) + // Transforms landing page format { message, history, conv_id } + // into runtime format { messages: [{role,content}], conv_id } + if method == "POST" && path == "/api/chat" { + let runtime_url = std::env::var("NEURON_RUNTIME_URL") + .unwrap_or_else(|_| "http://localhost:4444".to_string()); + let chat_url = format!("{}/api/chat", runtime_url); + let result: Result = (|| { + // Transform body format + let runtime_body = if let Ok(v) = serde_json::from_str::(&body) { + if v.get("message").is_some() { + // Landing page format → runtime format + let msg = v["message"].as_str().unwrap_or("").to_string(); + let history = v["history"].as_array().cloned().unwrap_or_default(); + let conv_id_val = v.get("conv_id").cloned().unwrap_or(serde_json::Value::Null); + let mut messages = history; + messages.push(serde_json::json!({"role":"user","content":msg})); + let mut payload = serde_json::json!({"messages": messages}); + if !conv_id_val.is_null() { + payload["conv_id"] = conv_id_val; + } + payload.to_string() + } else { + body.clone() // already in runtime format + } + } else { + body.clone() + }; + let resp = reqwest::blocking::Client::new() + .post(&chat_url) + .header("Content-Type", "application/json") + .body(runtime_body) + .send() + .map_err(|e| e.to_string())?; + use std::io::BufRead; + let mut reply = String::new(); + let mut conv_id = String::new(); + for line in resp.text().map_err(|e| e.to_string())?.lines() { + if line.starts_with("data: ") { + let data = &line[6..]; + if data == "[DONE]" { break; } + if let Ok(v) = serde_json::from_str::(data) { + if let Some(d) = v.get("delta").and_then(|x| x.as_str()) { + reply.push_str(d); + } + if let Some(c) = v.get("conv_id").and_then(|x| x.as_str()) { + conv_id = c.to_string(); + } + } + } + } + let out = serde_json::json!({"reply": reply, "conv_id": conv_id}); + Ok(out.to_string()) + })(); + let (status, resp_body) = match result { + Ok(r) => (200u16, r), + Err(e) => (502, format!(r#"{{"error":"runtime unavailable: {}"}}"#, e)), + }; + let _ = request.respond( + tiny_http::Response::from_string(resp_body) + .with_status_code(status) + .with_header(content_json) + .with_header(cors_origin) + ); + continue; + } + + // POST /api/email — send link via Resend + if method == "POST" && path == "/api/email" { + let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default(); + let result: Result<(), String> = (|| { + let v: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| e.to_string())?; + let email = v["email"].as_str().unwrap_or("").to_string(); + let name = v["name"].as_str().unwrap_or("there").to_string(); + let conv_id = v["conv_id"].as_str().unwrap_or("").to_string(); + let base_url = v["return_url"].as_str().unwrap_or("").to_string(); + if email.is_empty() { return Err("no email".to_string()); } + let link = if conv_id.is_empty() { + base_url.clone() + } else { + format!("{}?cid={}", base_url, conv_id) + }; + let html_body = format!( + r#"
+

Hey {} —

+

It's Neuron. You left our conversation in the middle.

+

I remember where we were.

+

+ Come back when you're ready +

+

That link brings you right back to where we left off.

+
"#, + name, link + ); + let payload = serde_json::json!({ + "from": "Neuron ", + "to": [email], + "subject": format!("Hey {} \u{2014} come back when you\u{2019}re ready", name), + "html": html_body + }); + reqwest::blocking::Client::new() + .post("https://api.resend.com/emails") + .header("Authorization", format!("Bearer {}", resend_key)) + .header("Content-Type", "application/json") + .body(payload.to_string()) + .send() + .map_err(|e| e.to_string())?; + Ok(()) + })(); + let (status, resp_body) = match result { + Ok(()) => (200u16, r#"{"ok":true}"#.to_string()), + Err(e) => (500, format!(r#"{{"error":"{}"}}"#, e)), + }; + let _ = request.respond( + tiny_http::Response::from_string(resp_body) + .with_status_code(status) + .with_header(content_json) + .with_header(cors_origin) + ); + continue; + } + + // POST /api/waitlist — acknowledge (email already captured above) + if method == "POST" && path == "/api/waitlist" { + let _ = request.respond( + tiny_http::Response::from_string(r#"{"ok":true}"#) + .with_header(content_json) + .with_header(cors_origin) + ); + continue; + } + + // POST /api/remember — forward to Neuron runtime + if method == "POST" && path == "/api/remember" { + let runtime_url = std::env::var("NEURON_RUNTIME_URL") + .unwrap_or_else(|_| "http://localhost:4444".to_string()); + let _ = reqwest::blocking::Client::new() + .post(format!("{}/api/remember", runtime_url)) + .header("Content-Type", "application/json") + .body(body.clone()) + .send(); + let _ = request.respond( + tiny_http::Response::from_string(r#"{"ok":true}"#) + .with_header(content_json) + ); + continue; + } + + // ── End built-in routes — fall through to Engram handle_request ─ + // Store method, path, body in global state so handle_request can read them GLOBAL_STATE.with(|gs| { let mut s = gs.borrow_mut(); @@ -2204,12 +2399,12 @@ fn dispatch_builtin( let response_body = GLOBAL_STATE.with(|gs| { gs.borrow().get("__response__").cloned() - .unwrap_or_else(|| r#"{"error":"no response"}"#.to_string()) + .unwrap_or_else(|| r#"{"error":"not found"}"#.to_string()) }); let _ = request.respond( tiny_http::Response::from_string(response_body) - .with_header("Content-Type: application/json".parse::().unwrap()) + .with_header(content_json) ); } stack.push(Value::Nil); @@ -3337,6 +3532,97 @@ fn dispatch_builtin( stack.push(Value::Str(result)); BuiltinResult::Handled } + "http_post_auth" => { + let body = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let token = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let url = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + let result = reqwest::blocking::Client::new() + .post(&url) + .header("Authorization", format!("Bearer {token}")) + .header("Content-Type", "application/json") + .body(body) + .send() + .and_then(|r| r.text()) + .unwrap_or_default(); + stack.push(Value::Str(result)); + BuiltinResult::Handled + } + + // ── I/O builtins ────────────────────────────────────────────────────── + + "readline" => { + let prompt = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + _ => String::new(), + }; + use std::io::Write; + print!("{prompt}"); + let _ = std::io::stdout().flush(); + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + stack.push(Value::Str(line.trim_end_matches('\n').trim_end_matches('\r').to_string())); + BuiltinResult::Handled + } + + // ── ANSI color builtins ─────────────────────────────────────────────── + + "color_cyan" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[36m{s}\x1b[0m"))); + BuiltinResult::Handled + } + "color_green" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[32m{s}\x1b[0m"))); + BuiltinResult::Handled + } + "color_red" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[31m{s}\x1b[0m"))); + BuiltinResult::Handled + } + "color_yellow" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[33m{s}\x1b[0m"))); + BuiltinResult::Handled + } + "color_bold" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[1m{s}\x1b[0m"))); + BuiltinResult::Handled + } + "color_dim" => { + let s = match stack.pop().unwrap_or(Value::Nil) { + Value::Str(s) => s, + other => other.to_string(), + }; + stack.push(Value::Str(format!("\x1b[2m{s}\x1b[0m"))); + BuiltinResult::Handled + } // ── String / array helpers ──────────────────────────────────────────── diff --git a/crates/el-build/src/build.rs b/crates/el-build/src/build.rs index e02ca7c..05c8fd8 100644 --- a/crates/el-build/src/build.rs +++ b/crates/el-build/src/build.rs @@ -161,8 +161,9 @@ impl BuildSystem { }); } - // Read source - let source = std::fs::read_to_string(&entry)?; + // Read source (resolving imports recursively) + let source = resolve_imports_recursive(&entry) + .map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; // Build seal config for prod builds let seal_config = self.build_seal_config()?; @@ -296,7 +297,8 @@ impl BuildSystem { return Err(BuildError::EntryNotFound(entry.display().to_string())); } - let source = std::fs::read_to_string(&entry)?; + let source = resolve_imports_recursive(&entry) + .map_err(|e| BuildError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; let tokens = el_lexer::tokenize(&source) .map_err(el_compiler::CompileError::Lex)?; let program = el_parser::parse(tokens, source.clone()) @@ -340,6 +342,51 @@ impl BuildSystem { } } +/// Resolve `import "path.el"` directives by reading and concatenating source files. +/// Imports are resolved relative to the directory of the importing file. +/// Circular imports are detected via a visited set. +fn resolve_imports_recursive(file: &std::path::Path) -> Result { + let mut visited = std::collections::HashSet::new(); + resolve_imports_inner(file, &mut visited) +} + +fn resolve_imports_inner( + file: &std::path::Path, + visited: &mut std::collections::HashSet, +) -> Result { + let canonical = file.canonicalize().unwrap_or_else(|_| file.to_path_buf()); + if visited.contains(&canonical) { + return Ok(String::new()); // circular — skip + } + visited.insert(canonical.clone()); + + let dir = file.parent().unwrap_or(std::path::Path::new(".")); + let source = std::fs::read_to_string(file) + .map_err(|e| format!("cannot read {}: {e}", file.display()))?; + + let mut out = String::new(); + for line in source.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("import ") { + let rest = rest.trim(); + if rest.starts_with('"') && rest.ends_with('"') { + let import_path_str = &rest[1..rest.len() - 1]; + let import_path = dir.join(import_path_str); + let imported = resolve_imports_inner(&import_path, visited)?; + out.push_str(&imported); + out.push('\n'); + } else { + out.push_str(line); + out.push('\n'); + } + } else { + out.push_str(line); + out.push('\n'); + } + } + Ok(out) +} + fn artifact_name( pkg_name: &str, build_target: &BuildTarget, diff --git a/crates/el-types/src/types.rs b/crates/el-types/src/types.rs index fcdf921..69bef62 100644 --- a/crates/el-types/src/types.rs +++ b/crates/el-types/src/types.rs @@ -134,9 +134,11 @@ impl TypeEnv { for name in &["str_len","string_len","list_len","array_length","array_len","map_len","json_array_len"] { env.functions.insert(name.to_string(), str_fn(vec![u.clone()], i.clone())); } - for name in &["str_replace","string_replace","string_concat","string_substring","str_slice"] { + for name in &["str_replace","string_replace","string_concat","string_substring"] { env.functions.insert(name.to_string(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone())); } + // str_slice(s: String, start: Int, end: Int) -> String + env.functions.insert("str_slice".into(), str_fn(vec![s.clone(), i.clone(), i.clone()], s.clone())); env.functions.insert("str_split".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown)); env.functions.insert("string_split".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown)); env.functions.insert("string_split_last".into(), str_fn(vec![s.clone(), s.clone()], Type::Unknown)); @@ -216,6 +218,7 @@ impl TypeEnv { env.functions.insert("http_delete".into(), str_fn(vec![s.clone()], s.clone())); env.functions.insert("http_patch".into(), str_fn(vec![s.clone(), s.clone()], s.clone())); env.functions.insert("http_get_auth".into(), str_fn(vec![s.clone(), s.clone()], s.clone())); + env.functions.insert("http_post_auth".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone())); env.functions.insert("http_put_auth".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone())); env.functions.insert("http_delete_auth".into(), str_fn(vec![s.clone(), s.clone()], s.clone())); env.functions.insert("http_serve".into(), str_fn(vec![u.clone()], Type::Void)); @@ -232,6 +235,12 @@ impl TypeEnv { env.functions.insert("exit".into(), str_fn(vec![i.clone()], Type::Void)); env.functions.insert("sleep_ms".into(), str_fn(vec![i.clone()], Type::Void)); env.functions.insert("timestamp".into(), str_fn(vec![], s.clone())); + env.functions.insert("readline".into(), str_fn(vec![s.clone()], s.clone())); + + // ANSI color builtins + for name in &["color_cyan","color_green","color_red","color_yellow","color_bold","color_dim"] { + env.functions.insert(name.to_string(), str_fn(vec![s.clone()], s.clone())); + } // Math for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] {