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:
Will Anderson
2026-04-28 13:46:22 -05:00
parent 094ca39b15
commit b62df85969
4 changed files with 354 additions and 6 deletions
+288 -2
View File
@@ -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 {} &mdash;</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 ────────────────────────────────────────────