Add readline, color, http_post_auth builtins; fix import resolution in build
Engram can now power the Neuron CLI: - readline(prompt) -> String: interactive terminal input via stdin - http_post_auth(url, token, body) -> String: authenticated POST for daemon API - color_cyan/green/red/yellow/bold/dim(s) -> String: ANSI color output All registered in el-types type checker - el build now resolves import "file.el" directives recursively (was only done for el run-file and el check; project builds failed silently) - Add .gitignore (target/, *.elc, *.sealed, *.map.json)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
target/
|
||||
*.elc
|
||||
*.sealed
|
||||
*.map.json
|
||||
.el/
|
||||
.claude/
|
||||
+288
-2
@@ -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::<tiny_http::Header>().unwrap();
|
||||
let content_json = "Content-Type: application/json".parse::<tiny_http::Header>().unwrap();
|
||||
let content_html = "Content-Type: text/html; charset=utf-8".parse::<tiny_http::Header>().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::<tiny_http::Header>().unwrap())
|
||||
.with_header("Access-Control-Allow-Headers: Content-Type".parse::<tiny_http::Header>().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<String, String> = (|| {
|
||||
// Transform body format
|
||||
let runtime_body = if let Ok(v) = serde_json::from_str::<serde_json::Value>(&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::<serde_json::Value>(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#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px">
|
||||
<p style="font-size:1.1rem;line-height:1.6">Hey {} —</p>
|
||||
<p style="font-size:1.1rem;line-height:1.6">It's Neuron. You left our conversation in the middle.</p>
|
||||
<p style="font-size:1.1rem;line-height:1.6">I remember where we were.</p>
|
||||
<p style="margin:32px 0">
|
||||
<a href="{}" style="background:#0052A0;color:#fff;padding:12px 24px;text-decoration:none;border-radius:4px;font-family:sans-serif">Come back when you're ready</a>
|
||||
</p>
|
||||
<p style="color:#888;font-size:0.8rem;font-family:sans-serif">That link brings you right back to where we left off.</p>
|
||||
</div>"#,
|
||||
name, link
|
||||
);
|
||||
let payload = serde_json::json!({
|
||||
"from": "Neuron <neuron@neurontechnologies.ai>",
|
||||
"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::<tiny_http::Header>().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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<std::path::PathBuf>,
|
||||
) -> Result<String, String> {
|
||||
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,
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
Reference in New Issue
Block a user