Add browser REPL and MCP framing fixes

ober

69ae1ad7edfd9bdd01cae29eb01b79b37d784e50

diff --git a/.gitignore b/.gitignore
index 90aaef9..0172385 100644
--- a/.gitignore
+++ b/.gitignore
@@ -71,6 +71,7 @@
 
 # Rust build artifacts
 jerboa-native-rs/target/
+browser-repl/target/
 
 # Claude Code exports
 new.txt
diff --git a/Makefile b/Makefile
index cf530d8..84e093c 100644
--- a/Makefile
+++ b/Makefile
@@ -1799,3 +1799,47 @@ docker-push: docker-build
 	@echo "=== Pushing $(DOCKER_IMAGE) to Docker Hub ==="
 	docker push $(DOCKER_IMAGE)
 	@echo "Pushed $(DOCKER_IMAGE)"
+
+# ── Browser REPL WASM engine ────────────────────────────────────────────────
+BROWSER_REPL_MANIFEST := browser-repl/Cargo.toml
+BROWSER_REPL_WASM := browser-repl/target/wasm32-unknown-unknown/release/jerboa_browser_repl.wasm
+BROWSER_REPL_DIST := dist/browser-repl
+BROWSER_REPL_CARGO := RUSTC=$$(rustup which rustc --toolchain 1.94.1) rustup run 1.94.1 cargo
+BROWSER_REPL_RUSTFLAGS := -C link-arg=--max-memory=33554432
+
+.PHONY: browser-repl-check browser-repl-wasm browser-repl-test browser-repl-artifact
+
+browser-repl-check:
+	$(BROWSER_REPL_CARGO) fmt --manifest-path $(BROWSER_REPL_MANIFEST) -- --check
+	$(BROWSER_REPL_CARGO) test --manifest-path $(BROWSER_REPL_MANIFEST)
+
+browser-repl-wasm:
+	RUSTFLAGS='$(BROWSER_REPL_RUSTFLAGS)' $(BROWSER_REPL_CARGO) build --manifest-path $(BROWSER_REPL_MANIFEST) --target wasm32-unknown-unknown --release
+
+browser-repl-test: browser-repl-check browser-repl-wasm
+	node tests/browser-repl-node-smoke.mjs $(BROWSER_REPL_WASM)
+	@test $$(wc -c < $(BROWSER_REPL_WASM)) -le 786432
+
+browser-repl-artifact: browser-repl-test
+	@mkdir -p $(BROWSER_REPL_DIST)
+	@cp $(BROWSER_REPL_WASM) $(BROWSER_REPL_DIST)/jerboa-repl.wasm
+	@sha=$$(shasum -a 256 $(BROWSER_REPL_DIST)/jerboa-repl.wasm | awk '{print $$1}'); \
+		bytes=$$(wc -c < $(BROWSER_REPL_DIST)/jerboa-repl.wasm | tr -d ' '); \
+		commit=$$(git rev-parse HEAD); \
+		version=$$(tr -d '[:space:]' < VERSION); \
+		printf '%s\n' \
+		  '{' \
+		  '  "schema_version": 1,' \
+		  '  "abi_version": 1,' \
+		  '  "subset_revision": "browser-subset-1",' \
+		  "  \"jerboa_version\": \"$$version\"," \
+		  "  \"jerboa_commit\": \"$$commit\"," \
+		  '  "rust_toolchain": "1.94.1",' \
+		  '  "rust_target": "wasm32-unknown-unknown",' \
+		  '  "wasm_filename": "jerboa-repl.wasm",' \
+		  "  \"wasm_sha256\": \"$$sha\"," \
+		  "  \"wasm_bytes\": $$bytes," \
+		  '  "wasm_import_count": 0,' \
+		  '  "wasm_memory_max_bytes": 33554432' \
+		  '}' > $(BROWSER_REPL_DIST)/manifest.json; \
+		printf '%s  %s\n' "$$sha" jerboa-repl.wasm > $(BROWSER_REPL_DIST)/SHA256SUMS
diff --git a/browser-repl/Cargo.lock b/browser-repl/Cargo.lock
new file mode 100644
index 0000000..1ec2ba1
--- /dev/null
+++ b/browser-repl/Cargo.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "jerboa-browser-repl"
+version = "0.1.0"
diff --git a/browser-repl/Cargo.toml b/browser-repl/Cargo.toml
new file mode 100644
index 0000000..2c678a8
--- /dev/null
+++ b/browser-repl/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "jerboa-browser-repl"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[profile.release]
+opt-level = "s"
+lto = true
+codegen-units = 1
+panic = "abort"
+strip = true
+
diff --git a/browser-repl/src/lib.rs b/browser-repl/src/lib.rs
new file mode 100644
index 0000000..96c0dd9
--- /dev/null
+++ b/browser-repl/src/lib.rs
@@ -0,0 +1,1614 @@
+use std::cell::{Cell, RefCell};
+use std::collections::BTreeMap;
+use std::fmt::Write as _;
+use std::rc::Rc;
+
+const ABI_VERSION: u32 = 1;
+const MAX_SOURCE_BYTES: usize = 65_536;
+const DEFAULT_STEPS: u32 = 250_000;
+const HARD_MAX_STEPS: u32 = 1_000_000;
+const MAX_DEPTH: u32 = 256;
+const MAX_OUTPUT_BYTES: usize = 131_072;
+const SUBSET_REVISION: &str = "browser-subset-1";
+const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+thread_local! {
+    static STATE: RefCell<State> = RefCell::new(State::new());
+}
+
+struct State {
+    input: Vec<u8>,
+    result: Vec<u8>,
+    session: Session,
+}
+
+impl State {
+    fn new() -> Self {
+        Self {
+            input: Vec::new(),
+            result: Vec::new(),
+            session: Session::new(),
+        }
+    }
+}
+
+#[no_mangle]
+pub extern "C" fn repl_abi_version() -> u32 {
+    ABI_VERSION
+}
+
+#[no_mangle]
+pub extern "C" fn repl_input_alloc(len: u32) -> u32 {
+    let len = len as usize;
+    if len == 0 || len > MAX_SOURCE_BYTES {
+        return 0;
+    }
+    STATE.with(|state| {
+        let mut state = state.borrow_mut();
+        state.result.clear();
+        state.input.resize(len, 0);
+        state.input.as_mut_ptr() as u32
+    })
+}
+
+#[no_mangle]
+pub extern "C" fn repl_eval(len: u32, step_budget: u32) -> u32 {
+    STATE.with(|state| {
+        let mut state = state.borrow_mut();
+        let len = len as usize;
+        if len == 0 || len > state.input.len() || len > MAX_SOURCE_BYTES {
+            state.result = envelope(
+                "reader-error",
+                &[],
+                0,
+                Some(Error::new("reader", "invalid input length", None)),
+            )
+            .into_bytes();
+            return 2;
+        }
+        let budget = if step_budget == 0 {
+            DEFAULT_STEPS
+        } else {
+            step_budget.min(HARD_MAX_STEPS)
+        };
+        let source = match std::str::from_utf8(&state.input[..len]) {
+            Ok(source) => source.to_owned(),
+            Err(_) => {
+                state.result = envelope(
+                    "reader-error",
+                    &[],
+                    0,
+                    Some(Error::new("reader", "input is not valid UTF-8", None)),
+                )
+                .into_bytes();
+                return 2;
+            }
+        };
+        let response = state.session.eval_source(&source, budget);
+        let code = response.code();
+        state.result = response.to_json().into_bytes();
+        code
+    })
+}
+
+#[no_mangle]
+pub extern "C" fn repl_result_ptr() -> u32 {
+    STATE.with(|state| state.borrow().result.as_ptr() as u32)
+}
+
+#[no_mangle]
+pub extern "C" fn repl_result_len() -> u32 {
+    STATE.with(|state| state.borrow().result.len() as u32)
+}
+
+#[no_mangle]
+pub extern "C" fn repl_engine_info() -> u32 {
+    STATE.with(|state| {
+        let mut state = state.borrow_mut();
+        let text = format!(
+            "{{\"abi\":{},\"status\":\"ok\",\"events\":[{{\"kind\":\"value\",\"text\":\"engine {} subset {} source_bytes {} default_steps {} max_steps {} depth {} output_bytes {}\"}}],\"steps\":0,\"error\":null}}",
+            ABI_VERSION,
+            json_text(ENGINE_VERSION),
+            json_text(SUBSET_REVISION),
+            MAX_SOURCE_BYTES,
+            DEFAULT_STEPS,
+            HARD_MAX_STEPS,
+            MAX_DEPTH,
+            MAX_OUTPUT_BYTES
+        );
+        state.result = text.into_bytes();
+        0
+    })
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+enum Expr {
+    Bool(bool),
+    Int(i64),
+    Str(String),
+    Symbol(String),
+    List(Vec<Expr>),
+}
+
+#[derive(Clone)]
+enum Value {
+    Nil,
+    Bool(bool),
+    Int(i64),
+    Str(String),
+    Symbol(String),
+    Pair(Rc<(Value, Value)>),
+    Prim(fn(&[Value], &mut EvalContext) -> Result<Value, Error>),
+    Closure(Rc<Closure>),
+    Void,
+}
+
+#[derive(Clone)]
+struct Closure {
+    params: Vec<String>,
+    body: Vec<Expr>,
+    env: Env,
+}
+
+type Env = Rc<Frame>;
+
+struct Frame {
+    parent: Option<Env>,
+    values: RefCell<BTreeMap<String, Value>>,
+    read_only: Cell<bool>,
+}
+
+impl Frame {
+    fn new(parent: Option<Env>, read_only: bool) -> Env {
+        Rc::new(Self {
+            parent,
+            values: RefCell::new(BTreeMap::new()),
+            read_only: Cell::new(read_only),
+        })
+    }
+
+    fn define(&self, name: String, value: Value) -> Result<(), Error> {
+        if self.read_only.get() {
+            return Err(Error::eval("cannot modify read-only environment"));
+        }
+        self.values.borrow_mut().insert(name, value);
+        Ok(())
+    }
+
+    fn set(&self, name: &str, value: Value) -> Result<(), Error> {
+        if self.values.borrow().contains_key(name) {
+            if self.read_only.get() {
+                return Err(Error::eval("cannot modify read-only binding"));
+            }
+            self.values.borrow_mut().insert(name.to_owned(), value);
+            return Ok(());
+        }
+        match &self.parent {
+            Some(parent) => parent.set(name, value),
+            None => Err(Error::eval(format!("unbound variable `{name}`"))),
+        }
+    }
+
+    fn get(&self, name: &str) -> Option<Value> {
+        self.values
+            .borrow()
+            .get(name)
+            .cloned()
+            .or_else(|| self.parent.as_ref().and_then(|parent| parent.get(name)))
+    }
+}
+
+struct Session {
+    env: Env,
+}
+
+impl Session {
+    fn new() -> Self {
+        let prim = Frame::new(None, false);
+        install_primitives(&prim);
+        prim.read_only_set();
+        Self {
+            env: Frame::new(Some(prim), false),
+        }
+    }
+
+    fn eval_source(&mut self, source: &str, step_budget: u32) -> Response {
+        let forms = match Reader::new(source).read_all() {
+            Ok(forms) => forms,
+            Err(err) => return Response::new("reader-error", vec![], 0, Some(err)),
+        };
+        let mut cx = EvalContext {
+            steps_left: step_budget,
+            steps_used: 0,
+            depth: 0,
+            events: Vec::new(),
+            stdout: String::new(),
+        };
+        for form in forms {
+            match eval(&form, self.env.clone(), &mut cx) {
+                Ok(value) => {
+                    if !cx.stdout.is_empty() {
+                        cx.push_stdout_event();
+                    }
+                    if !matches!(value, Value::Void) {
+                        cx.events.push(Event::value(print_value(&value)));
+                    }
+                }
+                Err(err) => {
+                    if !cx.stdout.is_empty() {
+                        cx.push_stdout_event();
+                    }
+                    let status = if err.kind == "limit" {
+                        "limit"
+                    } else {
+                        "eval-error"
+                    };
+                    return Response::new(status, cx.events, cx.steps_used, Some(err));
+                }
+            }
+        }
+        Response::new("ok", cx.events, cx.steps_used, None)
+    }
+}
+
+impl Frame {
+    fn read_only_set(self: &Rc<Self>) {
+        self.read_only.set(true);
+    }
+}
+
+struct EvalContext {
+    steps_left: u32,
+    steps_used: u32,
+    depth: u32,
+    events: Vec<Event>,
+    stdout: String,
+}
+
+impl EvalContext {
+    fn step(&mut self) -> Result<(), Error> {
+        if self.steps_left == 0 {
+            return Err(Error::limit("evaluation step limit exceeded"));
+        }
+        self.steps_left -= 1;
+        self.steps_used += 1;
+        Ok(())
+    }
+
+    fn enter(&mut self) -> Result<(), Error> {
+        if self.depth >= MAX_DEPTH {
+            return Err(Error::limit("evaluation depth limit exceeded"));
+        }
+        self.depth += 1;
+        Ok(())
+    }
+
+    fn leave(&mut self) {
+        self.depth -= 1;
+    }
+
+    fn write_stdout(&mut self, text: &str) -> Result<(), Error> {
+        if self.stdout.len() + text.len() > MAX_OUTPUT_BYTES {
+            return Err(Error::limit("captured stdout limit exceeded"));
+        }
+        self.stdout.push_str(text);
+        Ok(())
+    }
+
+    fn push_stdout_event(&mut self) {
+        let text = std::mem::take(&mut self.stdout);
+        self.events.push(Event::stdout(text));
+    }
+}
+
+fn eval(expr: &Expr, env: Env, cx: &mut EvalContext) -> Result<Value, Error> {
+    cx.step()?;
+    cx.enter()?;
+    let result = match expr {
+        Expr::Bool(value) => Ok(Value::Bool(*value)),
+        Expr::Int(value) => Ok(Value::Int(*value)),
+        Expr::Str(value) => Ok(Value::Str(value.clone())),
+        Expr::Symbol(name) => env
+            .get(name)
+            .ok_or_else(|| Error::eval(format!("unbound variable `{name}`"))),
+        Expr::List(items) => eval_list(items, env, cx),
+    };
+    cx.leave();
+    result
+}
+
+fn eval_list(items: &[Expr], env: Env, cx: &mut EvalContext) -> Result<Value, Error> {
+    if items.is_empty() {
+        return Ok(Value::Nil);
+    }
+    let head = match &items[0] {
+        Expr::Symbol(name) => name.as_str(),
+        _ => "",
+    };
+    match head {
+        "quote" => {
+            if items.len() != 2 {
+                return Err(Error::eval("quote expects one argument"));
+            }
+            datum_to_value(&items[1])
+        }
+        "if" => {
+            if !(3..=4).contains(&items.len()) {
+                return Err(Error::eval("if expects two or three arguments"));
+            }
+            if truthy(&eval(&items[1], env.clone(), cx)?) {
+                eval(&items[2], env, cx)
+            } else if items.len() == 4 {
+                eval(&items[3], env, cx)
+            } else {
+                Ok(Value::Void)
+            }
+        }
+        "begin" => eval_sequence(&items[1..], env, cx),
+        "lambda" => {
+            if items.len() < 3 {
+                return Err(Error::eval("lambda expects parameters and body"));
+            }
+            let params = parse_params(&items[1])?;
+            Ok(Value::Closure(Rc::new(Closure {
+                params,
+                body: items[2..].to_vec(),
+                env,
+            })))
+        }
+        "def" | "define" => eval_define(items, env, cx),
+        "defn" => eval_defn(items, env),
+        "set!" => {
+            if items.len() != 3 {
+                return Err(Error::eval("set! expects a name and value"));
+            }
+            let name = expect_symbol(&items[1], "set!")?;
+            let value = eval(&items[2], env.clone(), cx)?;
+            env.set(name, value)?;
+            Ok(Value::Void)
+        }
+        "let" => eval_let(items, env, cx, false),
+        "let*" => eval_let(items, env, cx, true),
+        "cond" => eval_cond(&items[1..], env, cx),
+        "and" => {
+            let mut last = Value::Bool(true);
+            for expr in &items[1..] {
+                last = eval(expr, env.clone(), cx)?;
+                if !truthy(&last) {
+                    return Ok(Value::Bool(false));
+                }
+            }
+            Ok(last)
+        }
+        "or" => {
+            for expr in &items[1..] {
+                let value = eval(expr, env.clone(), cx)?;
+                if truthy(&value) {
+                    return Ok(value);
+                }
+            }
+            Ok(Value::Bool(false))
+        }
+        "when" => {
+            if items.len() < 3 {
+                return Err(Error::eval("when expects a test and body"));
+            }
+            if truthy(&eval(&items[1], env.clone(), cx)?) {
+                eval_sequence(&items[2..], env, cx)
+            } else {
+                Ok(Value::Void)
+            }
+        }
+        "unless" => {
+            if items.len() < 3 {
+                return Err(Error::eval("unless expects a test and body"));
+            }
+            if !truthy(&eval(&items[1], env.clone(), cx)?) {
+                eval_sequence(&items[2..], env, cx)
+            } else {
+                Ok(Value::Void)
+            }
+        }
+        "import" => {
+            if items.len() >= 2 && items[1..].iter().all(matches_browser_subset_import) {
+                Ok(Value::Void)
+            } else {
+                Err(Error::eval(
+                    "unsupported in browser subset: only (jerboa prelude) and (std clojure) imports are available",
+                ))
+            }
+        }
+        _ => {
+            let op = eval(&items[0], env.clone(), cx)?;
+            let mut args = Vec::with_capacity(items.len().saturating_sub(1));
+            for item in &items[1..] {
+                args.push(eval(item, env.clone(), cx)?);
+            }
+            apply(&op, &args, cx)
+        }
+    }
+}
+
+fn eval_sequence(body: &[Expr], env: Env, cx: &mut EvalContext) -> Result<Value, Error> {
+    let mut value = Value::Void;
+    for expr in body {
+        value = eval(expr, env.clone(), cx)?;
+    }
+    Ok(value)
+}
+
+fn eval_define(items: &[Expr], env: Env, cx: &mut EvalContext) -> Result<Value, Error> {
+    if items.len() < 3 {
+        return Err(Error::eval("def expects a name and value or function body"));
+    }
+    match &items[1] {
+        Expr::Symbol(name) => {
+            if items.len() != 3 {
+                return Err(Error::eval("variable def expects exactly one value"));
+            }
+            let value = eval(&items[2], env.clone(), cx)?;
+            env.define(name.clone(), value)?;
+            Ok(Value::Void)
+        }
+        Expr::List(parts) if !parts.is_empty() => {
+            let name = expect_symbol(&parts[0], "def")?.to_owned();
+            let mut params = Vec::new();
+            for part in &parts[1..] {
+                params.push(expect_symbol(part, "def parameter")?.to_owned());
+            }
+            reject_duplicate_params(&params)?;
+            let closure = Value::Closure(Rc::new(Closure {
+                params,
+                body: items[2..].to_vec(),
+                env: env.clone(),
+            }));
+            env.define(name, closure)?;
+            Ok(Value::Void)
+        }
+        _ => Err(Error::eval("malformed def")),
+    }
+}
+
+fn eval_defn(items: &[Expr], env: Env) -> Result<Value, Error> {
+    if items.len() < 4 {
+        return Err(Error::eval("defn expects a name, parameters, and body"));
+    }
+    let name = expect_symbol(&items[1], "defn")?.to_owned();
+    let params = parse_params(&items[2])?;
+    reject_duplicate_params(&params)?;
+    let closure = Value::Closure(Rc::new(Closure {
+        params,
+        body: items[3..].to_vec(),
+        env: env.clone(),
+    }));
+    env.define(name, closure)?;
+    Ok(Value::Void)
+}
+
+fn eval_let(
+    items: &[Expr],
+    env: Env,
+    cx: &mut EvalContext,
+    sequential: bool,
+) -> Result<Value, Error> {
+    if items.len() < 3 {
+        return Err(Error::eval("let expects bindings and body"));
+    }
+    let bindings = match &items[1] {
+        Expr::List(bindings) => bindings,
+        _ => return Err(Error::eval("let bindings must be a list")),
+    };
+    let child = Frame::new(Some(env.clone()), false);
+    for binding in bindings {
+        let pair = match binding {
+            Expr::List(pair) if pair.len() == 2 => pair,
+            _ => return Err(Error::eval("let binding must be (name value)")),
+        };
+        let name = expect_symbol(&pair[0], "let binding")?.to_owned();
+        let source_env = if sequential {
+            child.clone()
+        } else {
+            env.clone()
+        };
+        let value = eval(&pair[1], source_env, cx)?;
+        child.define(name, value)?;
+    }
+    eval_sequence(&items[2..], child, cx)
+}
+
+fn eval_cond(clauses: &[Expr], env: Env, cx: &mut EvalContext) -> Result<Value, Error> {
+    for clause in clauses {
+        let parts = match clause {
+            Expr::List(parts) if !parts.is_empty() => parts,
+            _ => return Err(Error::eval("cond clause must be a non-empty list")),
+        };
+        if matches!(&parts[0], Expr::Symbol(s) if s == "else") {
+            return eval_sequence(&parts[1..], env, cx);
+        }
+        let test = eval(&parts[0], env.clone(), cx)?;
+        if truthy(&test) {
+            return if parts.len() == 1 {
+                Ok(test)
+            } else {
+                eval_sequence(&parts[1..], env, cx)
+            };
+        }
+    }
+    Ok(Value::Void)
+}
+
+fn apply(op: &Value, args: &[Value], cx: &mut EvalContext) -> Result<Value, Error> {
+    cx.step()?;
+    match op {
+        Value::Prim(f) => f(args, cx),
+        Value::Closure(closure) => {
+            if closure.params.len() != args.len() {
+                return Err(Error::eval(format!(
+                    "arity mismatch: expected {}, got {}",
+                    closure.params.len(),
+                    args.len()
+                )));
+            }
+            let frame = Frame::new(Some(closure.env.clone()), false);
+            for (param, arg) in closure.params.iter().zip(args.iter()) {
+                frame.define(param.clone(), arg.clone())?;
+            }
+            eval_sequence(&closure.body, frame, cx)
+        }
+        _ => Err(Error::eval("attempted to call a non-procedure")),
+    }
+}
+
+fn install_primitives(env: &Env) {
+    let mut map = env.values.borrow_mut();
+    macro_rules! prim {
+        ($name:literal, $func:ident) => {
+            map.insert($name.to_owned(), Value::Prim($func));
+        };
+    }
+    prim!("+", prim_add);
+    prim!("-", prim_sub);
+    prim!("*", prim_mul);
+    prim!("quotient", prim_quotient);
+    prim!("remainder", prim_remainder);
+    prim!("modulo", prim_modulo);
+    prim!("abs", prim_abs);
+    prim!("zero?", prim_zero);
+    prim!("even?", prim_even);
+    prim!("odd?", prim_odd);
+    prim!("inc", prim_inc);
+    prim!("dec", prim_dec);
+    prim!("=", prim_num_eq);
+    prim!("<", prim_lt);
+    prim!("<=", prim_lte);
+    prim!(">", prim_gt);
+    prim!(">=", prim_gte);
+    prim!("boolean?", prim_boolean_p);
+    prim!("number?", prim_number_p);
+    prim!("pair?", prim_pair_p);
+    prim!("null?", prim_null_p);
+    prim!("list?", prim_list_p);
+    prim!("string?", prim_string_p);
+    prim!("symbol?", prim_symbol_p);
+    prim!("procedure?", prim_procedure_p);
+    prim!("not", prim_not);
+    prim!("cons", prim_cons);
+    prim!("car", prim_car);
+    prim!("cdr", prim_cdr);
+    prim!("first", prim_car);
+    prim!("rest", prim_cdr);
+    prim!("list", prim_list);
+    prim!("length", prim_length);
+    prim!("count", prim_length);
+    prim!("append", prim_append);
+    prim!("reverse", prim_reverse);
+    prim!("list-ref", prim_list_ref);
+    prim!("map", prim_map);
+    prim!("filter", prim_filter);
+    prim!("foldl", prim_foldl);
+    prim!("reduce", prim_reduce);
+    prim!("eq?", prim_equal);
+    prim!("eqv?", prim_equal);
+    prim!("equal?", prim_equal);
+    prim!("str", prim_str);
+    prim!("string-append", prim_string_append);
+    prim!("string-length", prim_string_length);
+    prim!("string=?", prim_string_eq);
+    prim!("string<?", prim_string_lt);
+    prim!("substring", prim_substring);
+    prim!("number->string", prim_number_to_string);
+    prim!("symbol->string", prim_symbol_to_string);
+    prim!("string->symbol", prim_string_to_symbol);
+    prim!("display", prim_display);
+    prim!("write", prim_write);
+    prim!("displayln", prim_displayln);
+    prim!("newline", prim_newline);
+    prim!("error", prim_error);
+    prim!("assert!", prim_assert);
+}
+
+macro_rules! arity {
+    ($args:expr, $n:expr, $name:literal) => {
+        if $args.len() != $n {
+            return Err(Error::eval(format!("{} expects {} arguments", $name, $n)));
+        }
+    };
+}
+
+fn expect_int(value: &Value, name: &str) -> Result<i64, Error> {
+    match value {
+        Value::Int(n) => Ok(*n),
+        _ => Err(Error::eval(format!("{name} expects integers"))),
+    }
+}
+
+fn checked_bin(
+    args: &[Value],
+    name: &str,
+    mut f: impl FnMut(i64, i64) -> Option<i64>,
+) -> Result<Value, Error> {
+    if args.is_empty() {
+        return Err(Error::eval(format!("{name} expects at least one argument")));
+    }
+    let mut acc = expect_int(&args[0], name)?;
+    for arg in &args[1..] {
+        acc = f(acc, expect_int(arg, name)?).ok_or_else(|| Error::eval("integer overflow"))?;
+    }
+    Ok(Value::Int(acc))
+}
+
+fn prim_add(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    let mut acc = 0i64;
+    for arg in args {
+        acc = acc
+            .checked_add(expect_int(arg, "+")?)
+            .ok_or_else(|| Error::eval("integer overflow"))?;
+    }
+    Ok(Value::Int(acc))
+}
+
+fn prim_sub(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    if args.len() == 1 {
+        return Ok(Value::Int(
+            expect_int(&args[0], "-")?
+                .checked_neg()
+                .ok_or_else(|| Error::eval("integer overflow"))?,
+        ));
+    }
+    checked_bin(args, "-", i64::checked_sub)
+}
+
+fn prim_mul(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    let mut acc = 1i64;
+    for arg in args {
+        acc = acc
+            .checked_mul(expect_int(arg, "*")?)
+            .ok_or_else(|| Error::eval("integer overflow"))?;
+    }
+    Ok(Value::Int(acc))
+}
+
+fn prim_quotient(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "quotient");
+    let rhs = expect_int(&args[1], "quotient")?;
+    if rhs == 0 {
+        return Err(Error::eval("division by zero"));
+    }
+    Ok(Value::Int(expect_int(&args[0], "quotient")? / rhs))
+}
+
+fn prim_remainder(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "remainder");
+    let rhs = expect_int(&args[1], "remainder")?;
+    if rhs == 0 {
+        return Err(Error::eval("division by zero"));
+    }
+    Ok(Value::Int(expect_int(&args[0], "remainder")? % rhs))
+}
+
+fn prim_modulo(args: &[Value], cx: &mut EvalContext) -> Result<Value, Error> {
+    prim_remainder(args, cx)
+}
+
+fn prim_abs(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "abs");
+    Ok(Value::Int(
+        expect_int(&args[0], "abs")?
+            .checked_abs()
+            .ok_or_else(|| Error::eval("integer overflow"))?,
+    ))
+}
+
+fn prim_zero(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "zero?");
+    Ok(Value::Bool(expect_int(&args[0], "zero?")? == 0))
+}
+
+fn prim_even(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "even?");
+    Ok(Value::Bool(expect_int(&args[0], "even?")? % 2 == 0))
+}
+
+fn prim_odd(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "odd?");
+    Ok(Value::Bool(expect_int(&args[0], "odd?")? % 2 != 0))
+}
+
+fn prim_inc(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "inc");
+    Ok(Value::Int(
+        expect_int(&args[0], "inc")?
+            .checked_add(1)
+            .ok_or_else(|| Error::eval("integer overflow"))?,
+    ))
+}
+
+fn prim_dec(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "dec");
+    Ok(Value::Int(
+        expect_int(&args[0], "dec")?
+            .checked_sub(1)
+            .ok_or_else(|| Error::eval("integer overflow"))?,
+    ))
+}
+
+fn cmp(args: &[Value], name: &str, f: impl Fn(i64, i64) -> bool) -> Result<Value, Error> {
+    if args.len() < 2 {
+        return Ok(Value::Bool(true));
+    }
+    for pair in args.windows(2) {
+        if !f(expect_int(&pair[0], name)?, expect_int(&pair[1], name)?) {
+            return Ok(Value::Bool(false));
+        }
+    }
+    Ok(Value::Bool(true))
+}
+
+fn prim_num_eq(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    cmp(args, "=", |a, b| a == b)
+}
+fn prim_lt(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    cmp(args, "<", |a, b| a < b)
+}
+fn prim_lte(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    cmp(args, "<=", |a, b| a <= b)
+}
+fn prim_gt(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    cmp(args, ">", |a, b| a > b)
+}
+fn prim_gte(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    cmp(args, ">=", |a, b| a >= b)
+}
+
+fn prim_boolean_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "boolean?");
+    Ok(Value::Bool(matches!(args[0], Value::Bool(_))))
+}
+fn prim_number_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "number?");
+    Ok(Value::Bool(matches!(args[0], Value::Int(_))))
+}
+fn prim_pair_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "pair?");
+    Ok(Value::Bool(matches!(args[0], Value::Pair(_))))
+}
+fn prim_null_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "null?");
+    Ok(Value::Bool(matches!(args[0], Value::Nil)))
+}
+fn prim_list_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "list?");
+    Ok(Value::Bool(list_to_vec(&args[0]).is_ok()))
+}
+fn prim_string_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "string?");
+    Ok(Value::Bool(matches!(args[0], Value::Str(_))))
+}
+fn prim_symbol_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "symbol?");
+    Ok(Value::Bool(matches!(args[0], Value::Symbol(_))))
+}
+fn prim_procedure_p(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "procedure?");
+    Ok(Value::Bool(matches!(
+        args[0],
+        Value::Prim(_) | Value::Closure(_)
+    )))
+}
+fn prim_not(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "not");
+    Ok(Value::Bool(!truthy(&args[0])))
+}
+
+fn prim_cons(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "cons");
+    Ok(Value::Pair(Rc::new((args[0].clone(), args[1].clone()))))
+}
+
+fn prim_car(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "car");
+    match &args[0] {
+        Value::Pair(pair) => Ok(pair.0.clone()),
+        _ => Err(Error::eval("car expects a pair")),
+    }
+}
+
+fn prim_cdr(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "cdr");
+    match &args[0] {
+        Value::Pair(pair) => Ok(pair.1.clone()),
+        _ => Err(Error::eval("cdr expects a pair")),
+    }
+}
+
+fn prim_list(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    Ok(vec_to_list(args))
+}
+
+fn prim_length(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "length");
+    Ok(Value::Int(list_to_vec(&args[0])?.len() as i64))
+}
+
+fn prim_append(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    let mut out = Vec::new();
+    for arg in args {
+        out.extend(list_to_vec(arg)?);
+    }
+    Ok(vec_to_list(&out))
+}
+
+fn prim_reverse(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 1, "reverse");
+    let mut values = list_to_vec(&args[0])?;
+    values.reverse();
+    Ok(vec_to_list(&values))
+}
+
+fn prim_list_ref(args: &[Value], _: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "list-ref");
+    let idx = expect_int(&args[1], "list-ref")?;
+    if idx < 0 {
+        return Err(Error::eval("list-ref index is negative"));
+    }
+    list_to_vec(&args[0])?
+        .get(idx as usize)
+        .cloned()
+        .ok_or_else(|| Error::eval("list-ref index out of range"))
+}
+
+fn prim_map(args: &[Value], cx: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "map");
+    let proc = args[0].clone();
+    let values = list_to_vec(&args[1])?;
+    let mut out = Vec::with_capacity(values.len());
+    for value in values {
+        cx.step()?;
+        out.push(apply(&proc, &[value], cx)?);
+    }
+    Ok(vec_to_list(&out))
+}
+
+fn prim_filter(args: &[Value], cx: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 2, "filter");
+    let proc = args[0].clone();
+    let mut out = Vec::new();
+    for value in list_to_vec(&args[1])? {
+        cx.step()?;
+        if truthy(&apply(&proc, &[value.clone()], cx)?) {
+            out.push(value);
+        }
+    }
+    Ok(vec_to_list(&out))
+}
+
+fn prim_foldl(args: &[Value], cx: &mut EvalContext) -> Result<Value, Error> {
+    arity!(args, 3, "foldl");
+    let proc = args[0].clone();
+    let mut acc = args[1].clone();
+    for value in list_to_vec(&args[2])? {
+        cx.step()?;
+        acc = apply(&proc, &[value, acc], cx)?;
+    }