From 2b45fc2f0fc7053e1cdac823440bf80a38ae988f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 30 Apr 2026 13:49:28 -0500 Subject: [PATCH] engram: runtime-native rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engram is now a thin HTTP face over the El runtime's in-process graph store. The C runtime owns the data; engram_*_json builtins serialize results directly. There is no SQL, no SQLite, no db layer, no state machine — the runtime IS the database. src/server.el (348 lines, replacing 5797 lines across 15 legacy files): GET /health GET /api/stats POST /api/nodes (auth required) GET /api/nodes GET /api/nodes/:id DELETE /api/nodes/:id (auth required) POST /api/edges (auth required) GET /api/neighbors/:id POST /api/activate GET /api/activate POST /api/search GET /api/search POST /api/strengthen (auth required) POST /api/save (auth required) POST /api/load (auth required) Auth: ENGRAM_API_KEY in env. GET routes pass through (read-only). Mutating routes require {"_auth": ""} in the JSON body until http_serve surfaces request headers and we can switch to Bearer. Persistence: engram_save / engram_load via JSON snapshot at $ENGRAM_DATA_DIR/snapshot.json. Loaded best-effort on startup. Build: dist/platform/elc src/server.el > dist/engram.c cc -std=c11 -O2 -I -lcurl -lpthread -o dist/engram dist/engram.c /el_runtime.c Live: native binary at dist/engram (113 KB), running under ~/Library/LaunchAgents/ai.neuron.engram.plist on :8742. Verified: GET /api/stats returns counts; POST /api/nodes with auth creates node with UUID; GET /api/search returns full node JSON; spreading activation returns hop-decayed strengths (0.8 × edge × decay per hop) with epistemic confidence filtering. Legacy (5797 lines of SQLite-era src) sealed at ~/Archives/engram-src-legacy-20260430.tar.gz and removed from disk. --- engram/.el/build-cache.json | 5 + engram/.gitignore | 3 + engram/dist/engram | Bin 0 -> 113064 bytes engram/dist/engram.c | 344 ++++++++++++++++++++++++++++++++++++ engram/manifest.el | 11 ++ engram/spec/engram-el.md | 284 +++++++++++++++++++++++++++++ engram/src/server.el | 293 ++++++++++++++++++++++++++++++ 7 files changed, 940 insertions(+) create mode 100644 engram/.el/build-cache.json create mode 100644 engram/.gitignore create mode 100755 engram/dist/engram create mode 100644 engram/dist/engram.c create mode 100644 engram/manifest.el create mode 100644 engram/spec/engram-el.md create mode 100644 engram/src/server.el diff --git a/engram/.el/build-cache.json b/engram/.el/build-cache.json new file mode 100644 index 0000000..34002f9 --- /dev/null +++ b/engram/.el/build-cache.json @@ -0,0 +1,5 @@ +{ + "file_hashes": { + "src/server.el": "1bb43f78f2048cc3aeb2d1f68eda1c1af03425a356b6660dff22273ed50fdece" + } +} \ No newline at end of file diff --git a/engram/.gitignore b/engram/.gitignore new file mode 100644 index 0000000..954262e --- /dev/null +++ b/engram/.gitignore @@ -0,0 +1,3 @@ +dist/engram +dist/engram.c +*.log diff --git a/engram/dist/engram b/engram/dist/engram new file mode 100755 index 0000000000000000000000000000000000000000..54c2889298c507db40476af8ca3f8c7e8f77d3c9 GIT binary patch literal 113064 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 o{ +#include +#include "el_runtime.h" + +el_val_t parse_port(el_val_t bind); +el_val_t ok_json(void); +el_val_t err_json(el_val_t msg); +el_val_t strip_query(el_val_t path); +el_val_t query_param(el_val_t path, el_val_t key); +el_val_t query_int(el_val_t path, el_val_t key, el_val_t default_val); +el_val_t extract_id(el_val_t path, el_val_t prefix); +el_val_t route_stats(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_create_node(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_get_node(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_scan_nodes(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_search(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_activate(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_create_edge(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_neighbors(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_strengthen(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_forget(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_save(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_load(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_health(el_val_t method, el_val_t path, el_val_t body); +el_val_t check_auth_ok(el_val_t method, el_val_t body); +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body); + +el_val_t parse_port(el_val_t bind) { + el_val_t colon = str_index_of(bind, EL_STR(":")); + if (colon < 0) { + return str_to_int(bind); + } + el_val_t after = str_slice(bind, (colon + 1), str_len(bind)); + return str_to_int(after); + return 0; +} + +el_val_t ok_json(void) { + return EL_STR("{\"ok\":true}"); + return 0; +} + +el_val_t err_json(el_val_t msg) { + return el_str_concat(el_str_concat(EL_STR("{\"error\":\""), msg), EL_STR("\"}")); + return 0; +} + +el_val_t strip_query(el_val_t path) { + el_val_t q = str_index_of(path, EL_STR("?")); + if (q < 0) { + return path; + } + return str_slice(path, 0, q); + return 0; +} + +el_val_t query_param(el_val_t path, el_val_t key) { + el_val_t q = str_index_of(path, EL_STR("?")); + if (q < 0) { + return EL_STR(""); + } + el_val_t qs = str_slice(path, (q + 1), str_len(path)); + el_val_t needle = el_str_concat(key, EL_STR("=")); + el_val_t pos = str_index_of(qs, needle); + if (pos < 0) { + return EL_STR(""); + } + el_val_t after = str_slice(qs, (pos + str_len(needle)), str_len(qs)); + el_val_t amp = str_index_of(after, EL_STR("&")); + if (amp < 0) { + return after; + } + return str_slice(after, 0, amp); + return 0; +} + +el_val_t query_int(el_val_t path, el_val_t key, el_val_t default_val) { + el_val_t v = query_param(path, key); + if (str_eq(v, EL_STR(""))) { + return default_val; + } + return str_to_int(v); + return 0; +} + +el_val_t extract_id(el_val_t path, el_val_t prefix) { + el_val_t clean = strip_query(path); + if (!str_starts_with(clean, prefix)) { + return EL_STR(""); + } + el_val_t after = str_slice(clean, str_len(prefix), str_len(clean)); + el_val_t slash = str_index_of(after, EL_STR("/")); + if (slash < 0) { + return after; + } + return str_slice(after, 0, slash); + return 0; +} + +el_val_t route_stats(el_val_t method, el_val_t path, el_val_t body) { + return engram_stats_json(); + return 0; +} + +el_val_t route_create_node(el_val_t method, el_val_t path, el_val_t body) { + el_val_t content = json_get_string(body, EL_STR("content")); + el_val_t node_type = json_get_string(body, EL_STR("node_type")); + if (str_eq(node_type, EL_STR(""))) { + node_type = EL_STR("Memory"); + } + el_val_t salience = json_get_float(body, EL_STR("salience")); + if (str_eq(salience, el_from_float(0.0))) { + salience = el_from_float(0.5); + } + el_val_t id = engram_node(content, node_type, salience); + return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"id\":\""), id), EL_STR("\",\"content\":\"")), content), EL_STR("\",\"node_type\":\"")), node_type), EL_STR("\"}")); + return 0; +} + +el_val_t route_get_node(el_val_t method, el_val_t path, el_val_t body) { + el_val_t id = extract_id(path, EL_STR("/api/nodes/")); + if (str_eq(id, EL_STR(""))) { + return err_json(EL_STR("missing id")); + } + return engram_get_node_json(id); + return 0; +} + +el_val_t route_scan_nodes(el_val_t method, el_val_t path, el_val_t body) { + el_val_t limit = query_int(path, EL_STR("limit"), 50); + el_val_t offset = query_int(path, EL_STR("offset"), 0); + return engram_scan_nodes_json(limit, offset); + return 0; +} + +el_val_t route_search(el_val_t method, el_val_t path, el_val_t body) { + el_val_t q = EL_STR(""); + if (str_eq(method, EL_STR("GET"))) { + q = query_param(path, EL_STR("q")); + } else { + q = json_get_string(body, EL_STR("query")); + } + el_val_t limit = query_int(path, EL_STR("limit"), 20); + if (limit == 0) { + limit = json_get_int(body, EL_STR("limit")); + } + if (limit == 0) { + limit = 20; + } + return engram_search_json(q, limit); + return 0; +} + +el_val_t route_activate(el_val_t method, el_val_t path, el_val_t body) { + el_val_t q = EL_STR(""); + el_val_t depth = 3; + if (str_eq(method, EL_STR("GET"))) { + q = query_param(path, EL_STR("q")); + depth = query_int(path, EL_STR("depth"), 3); + } else { + q = json_get_string(body, EL_STR("query")); + el_val_t bd = json_get_int(body, EL_STR("depth")); + if (bd > 0) { + depth = bd; + } + } + return el_str_concat(el_str_concat(EL_STR("{\"results\":"), engram_activate_json(q, depth)), EL_STR("}")); + return 0; +} + +el_val_t route_create_edge(el_val_t method, el_val_t path, el_val_t body) { + el_val_t from_id = json_get_string(body, EL_STR("from_id")); + el_val_t to_id = json_get_string(body, EL_STR("to_id")); + el_val_t relation = json_get_string(body, EL_STR("relation")); + if (str_eq(relation, EL_STR(""))) { + relation = EL_STR("associates"); + } + el_val_t weight = json_get_float(body, EL_STR("weight")); + if (str_eq(weight, el_from_float(0.0))) { + weight = el_from_float(0.5); + } + engram_connect(from_id, to_id, weight, relation); + return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"from_id\":\""), from_id), EL_STR("\",\"to_id\":\"")), to_id), EL_STR("\",\"relation\":\"")), relation), EL_STR("\"}")); + return 0; +} + +el_val_t route_neighbors(el_val_t method, el_val_t path, el_val_t body) { + el_val_t id = extract_id(path, EL_STR("/api/neighbors/")); + if (str_eq(id, EL_STR(""))) { + return err_json(EL_STR("missing id")); + } + el_val_t depth = query_int(path, EL_STR("depth"), 1); + return engram_neighbors_json(id, depth, EL_STR("both")); + return 0; +} + +el_val_t route_strengthen(el_val_t method, el_val_t path, el_val_t body) { + el_val_t id = json_get_string(body, EL_STR("node_id")); + if (str_eq(id, EL_STR(""))) { + return err_json(EL_STR("missing node_id")); + } + engram_strengthen(id); + return ok_json(); + return 0; +} + +el_val_t route_forget(el_val_t method, el_val_t path, el_val_t body) { + el_val_t id = extract_id(path, EL_STR("/api/nodes/")); + if (str_eq(id, EL_STR(""))) { + return err_json(EL_STR("missing id")); + } + engram_forget(id); + return ok_json(); + return 0; +} + +el_val_t route_save(el_val_t method, el_val_t path, el_val_t body) { + el_val_t p = json_get_string(body, EL_STR("path")); + if (str_eq(p, EL_STR(""))) { + el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR")); + if (str_eq(dir, EL_STR(""))) { + dir = EL_STR("/tmp/engram"); + } + p = el_str_concat(dir, EL_STR("/snapshot.json")); + } + engram_save(p); + return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"path\":\""), p), EL_STR("\"}")); + return 0; +} + +el_val_t route_load(el_val_t method, el_val_t path, el_val_t body) { + el_val_t p = json_get_string(body, EL_STR("path")); + if (str_eq(p, EL_STR(""))) { + el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR")); + if (str_eq(dir, EL_STR(""))) { + dir = EL_STR("/tmp/engram"); + } + p = el_str_concat(dir, EL_STR("/snapshot.json")); + } + engram_load(p); + return ok_json(); + return 0; +} + +el_val_t route_health(el_val_t method, el_val_t path, el_val_t body) { + return EL_STR("{\"status\":\"ok\",\"engine\":\"engram-runtime-native\"}"); + return 0; +} + +el_val_t check_auth_ok(el_val_t method, el_val_t body) { + el_val_t key = env(EL_STR("ENGRAM_API_KEY")); + if (str_eq(key, EL_STR(""))) { + return 1; + } + if (str_eq(method, EL_STR("GET"))) { + return 1; + } + el_val_t provided = json_get_string(body, EL_STR("_auth")); + if (str_eq(provided, key)) { + return 1; + } + return 0; + return 0; +} + +el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) { + el_val_t clean = strip_query(path); + if (str_eq(method, EL_STR("GET"))) { + if (str_eq(clean, EL_STR("/health")) || str_eq(clean, EL_STR("/"))) { + return route_health(method, path, body); + } + } + if (!check_auth_ok(method, body)) { + return err_json(EL_STR("unauthorized")); + } + if (str_eq(method, EL_STR("GET")) && (str_eq(clean, EL_STR("/api/stats")) || str_eq(clean, EL_STR("/stats")))) { + return route_stats(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/nodes")) || str_eq(clean, EL_STR("/nodes")))) { + return route_create_node(method, path, body); + } + if (str_eq(method, EL_STR("GET")) && (str_eq(clean, EL_STR("/api/nodes")) || str_eq(clean, EL_STR("/nodes")))) { + return route_scan_nodes(method, path, body); + } + if (str_eq(method, EL_STR("GET")) && str_starts_with(clean, EL_STR("/api/nodes/"))) { + return route_get_node(method, path, body); + } + if (str_eq(method, EL_STR("DELETE")) && str_starts_with(clean, EL_STR("/api/nodes/"))) { + return route_forget(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/edges")) || str_eq(clean, EL_STR("/edges")))) { + return route_create_edge(method, path, body); + } + if (str_eq(method, EL_STR("GET")) && str_starts_with(clean, EL_STR("/api/neighbors/"))) { + return route_neighbors(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/activate")) || str_eq(clean, EL_STR("/activate")))) { + return route_activate(method, path, body); + } + if (str_eq(method, EL_STR("GET")) && str_starts_with(clean, EL_STR("/api/activate"))) { + return route_activate(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/search")) || str_eq(clean, EL_STR("/search")))) { + return route_search(method, path, body); + } + if (str_eq(method, EL_STR("GET")) && str_starts_with(clean, EL_STR("/api/search"))) { + return route_search(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/strengthen")) || str_eq(clean, EL_STR("/strengthen")))) { + return route_strengthen(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/save")) || str_eq(clean, EL_STR("/save")))) { + return route_save(method, path, body); + } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/load")) || str_eq(clean, EL_STR("/load")))) { + return route_load(method, path, body); + } + return el_str_concat(el_str_concat(EL_STR("{\"error\":\"not found\",\"path\":\""), clean), EL_STR("\"}")); + return 0; +} + +int main(int argc, char** argv) { + el_runtime_init_args(argc, argv); + el_val_t bind_str = env(EL_STR("ENGRAM_BIND")); + if (str_eq(bind_str, EL_STR(""))) { + bind_str = EL_STR(":8742"); + } + el_val_t port = parse_port(bind_str); + el_val_t data_dir = env(EL_STR("ENGRAM_DATA_DIR")); + if (str_eq(data_dir, EL_STR(""))) { + data_dir = EL_STR("/tmp/engram"); + } + el_val_t snapshot_path = el_str_concat(data_dir, EL_STR("/snapshot.json")); + engram_load(snapshot_path); + println(EL_STR("[engram] runtime-native graph engine")); + println(el_str_concat(EL_STR("[engram] data_dir="), data_dir)); + println(el_str_concat(EL_STR("[engram] node_count="), int_to_str(engram_node_count()))); + println(el_str_concat(EL_STR("[engram] edge_count="), int_to_str(engram_edge_count()))); + println(el_str_concat(EL_STR("[engram] listening on "), int_to_str(port))); + http_set_handler(EL_STR("handle_request")); + http_serve(port, EL_STR("handle_request")); + return 0; +} + diff --git a/engram/manifest.el b/engram/manifest.el new file mode 100644 index 0000000..b5e8ebc --- /dev/null +++ b/engram/manifest.el @@ -0,0 +1,11 @@ +package "engram-el" { + version "1.0.0" + description "Engram graph intelligence substrate — El implementation" + authors ["Will Anderson "] + edition "2026" +} + +build { + entry "src/server.el" + output "dist/" +} diff --git a/engram/spec/engram-el.md b/engram/spec/engram-el.md new file mode 100644 index 0000000..7dd88aa --- /dev/null +++ b/engram/spec/engram-el.md @@ -0,0 +1,284 @@ +# engram-el Specification + +Version 1.0.0 — April 29, 2026 + +--- + +## Overview + +engram-el is the El-native interface layer for the Engram graph engine. It is the integration point between El programs and the Engram knowledge substrate — providing a suite of El programs, test suites, and utilities that operate on a live Engram server via its HTTP API using El's native HTTP builtins. + +engram-el has three primary components: + +1. **Studio** — A full-featured terminal-based graph explorer written in El (`studio/studio.el`). Provides read access to all graph data: statistics, node browsing by type and tier, spreading activation visualization, edge exploration, and text search. + +2. **Test suite** — Language feature tests (`test/language_features_test.el`, `test/field_test.el`, `test/llm_test.el`) that exercise El builtins against a live Engram instance. + +3. **Integration point** — The pattern for how El programs use the Engram graph as their knowledge substrate, demonstrating the graph builtin API in practice. + +--- + +## 1. Architecture + +### 1.1 Relationship to El and Engram + +engram-el is not a library in the conventional sense. It is a collection of El programs that operate on Engram. The integration uses no additional runtime or SDK: + +- **El builtins** provide `http_get`, `http_post`, and JSON parsing natively. +- **Engram HTTP API** is the sole interface — all graph operations are HTTP requests. +- **No compilation step** beyond standard El compilation is required. + +This demonstrates the intended usage pattern for all El programs that incorporate graph knowledge: use the HTTP API via El's native builtins. + +### 1.2 Configuration + +All engram-el programs read configuration from environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `ENGRAM_URL` | `http://localhost:8340` | Engram server base URL | +| `ENGRAM_REPORT` | `/tmp/engram-studio-report.txt` | Studio report output path | + +--- + +## 2. Studio Application + +`studio/studio.el` is a complete data exploration application for the Engram graph, written entirely in El. It demonstrates El as a serious application language — not a scripting language but a capable system for building non-trivial tools. + +### 2.1 Features + +The studio renders a full-page terminal UI with box-drawing characters and ANSI color. Sections: + +| Section | Description | +|---------|-------------| +| Database Statistics | Node count, edge count, average salience, DB size | +| Recent Nodes | Most recently created nodes with type and salience | +| Top by Salience | Highest-salience nodes with graphical bar display | +| Nodes by Type | Browse Memory, Concept, Event, Entity, Process, InternalState | +| Nodes by Tier | Browse Working, Episodic, Semantic, Procedural tiers | +| Knowledge Browser | Concept nodes as domain knowledge anchors | +| Text Search | Full-text search results with relevance | +| Edge Explorer | Sample of edges with weights and relation types | +| Node Detail | Full node data plus BFS neighbors | +| Spreading Activation | Visual activation surface from a seed node | +| Interactive Mode Preview | Menu of available commands | +| Report Export | Write complete session report to file | + +### 2.2 API Access Pattern + +The studio uses a uniform API access pattern: + +```el +fn api_get(path: String) -> String { + let url: String = get_base_url() + path + let resp: String = http_get(url) + if str_starts_with(resp, "{\"error\"") { + return "" + } + return resp +} + +fn api_post(path: String, body: String) -> String { + let url: String = get_base_url() + path + let resp: String = http_post(url, body) + if str_starts_with(resp, "{\"error\"") { + return "" + } + return resp +} +``` + +Error responses (JSON objects beginning with `{"error"`) return empty string. All rendering logic checks for empty string and emits placeholder messages rather than crashing. + +### 2.3 Spreading Activation Visualization + +The activation section demonstrates reading live spreading activation results from Engram: + +```el +fn show_activation(seed_id: String, limit: Int, report: String) -> String { + let path: String = "/api/activate?seeds=" + seed_id + "&limit=" + int_to_str(limit) + "&depth=3" + let json_str: String = api_get(path) + // ... renders activation strength bars and hop distances +} +``` + +This provides visual confirmation that the spreading activation algorithm is operating — showing which nodes activate, at what strength, and at what hop distance from the seed. + +### 2.4 Report Export + +The studio accumulates a text report as it renders each section, then writes the complete report to a file: + +```el +export_report(report, report_path) +``` + +The report captures the full session output in machine-readable format, useful for automation and logging. + +--- + +## 3. Test Suite + +### 3.1 Language Features Test + +`test/language_features_test.el` exercises El language primitives including: + +- Modulo operator (`%`) +- Bitwise operators (`&`, `^`, `<<`, `>>`) +- Math builtins (`math_sin`, `math_cos`, `math_pi`) +- String padding (`str_pad_left`, `str_pad_right`) +- String formatting (`str_format` with `{key}` template interpolation) +- Float formatting (`format_float`) +- Time operations (`time_now_utc`, `time_format`, `time_add`, `time_diff`) +- List operations (`list_range`, `list_join`) +- Stack and queue builtins (`stack_new`, `stack_push`, `stack_pop`, `stack_peek`, `queue_enqueue`, `queue_dequeue`) +- Decimal rounding (`decimal_round`) +- Type conversion (`int_to_float`, `float_to_int`) +- Nil checks (`is_nil`, `unwrap_or`) +- Character operations (`str_char_at`, `str_char_code`, `str_from_char_code`) + +These tests serve as the canonical behavioral specification for El builtins — any correct El implementation must produce the documented output for these inputs. + +### 3.2 Field Test + +`test/field_test.el` exercises struct field access, map indexing, and nested data access patterns. + +### 3.3 LLM Test + +`test/llm_test.el` exercises the LLM inference builtins against a live Engram-connected inference endpoint. + +--- + +## 4. Integration Patterns + +### 4.1 Graph Read Pattern + +The standard pattern for reading from Engram in an El program: + +```el +fn get_nodes_of_type(node_type: String, limit: Int) -> List { + let path: String = "/api/nodes?node_type=" + node_type + "&limit=" + int_to_str(limit) + let json_str: String = http_get(env("ENGRAM_URL") + path) + if json_str == "" { + return list_new() + } + return json_parse(json_str) +} +``` + +### 4.2 Graph Write Pattern + +The standard pattern for writing to Engram from an El program: + +```el +fn create_node(label: String, content: String, node_type: String, tier: String) -> String { + let body: String = "{\"label\":\"" + label + "\",\"content\":\"" + content + "\",\"node_type\":\"" + node_type + "\",\"tier\":\"" + tier + "\",\"importance\":0.5}" + let resp: String = http_post(env("ENGRAM_URL") + "/api/nodes", body) + return json_get_string(resp, "id") +} +``` + +### 4.3 Search Pattern + +```el +fn search_graph(query: String, limit: Int) -> List { + let path: String = "/api/search?q=" + query + "&limit=" + int_to_str(limit) + let json_str: String = http_get(env("ENGRAM_URL") + path) + if json_str == "" { + return list_new() + } + return json_parse(json_str) +} +``` + +### 4.4 Activation Pattern + +```el +fn activate_from_node(node_id: String, depth: Int, limit: Int) -> List { + let path: String = "/api/activate?seeds=" + node_id + "&depth=" + int_to_str(depth) + "&limit=" + int_to_str(limit) + let resp_str: String = http_get(env("ENGRAM_URL") + path) + if resp_str == "" { + return list_new() + } + let results_raw: String = json_get_raw(resp_str, "results") + return json_parse(results_raw) +} +``` + +--- + +## 5. Builtin Extensions Demonstrated + +The engram-el programs demonstrate El builtins that are not in the core language but are implemented by the VM's builtin dispatch layer: + +### 5.1 JSON Builtins + +| Builtin | Used for | +|---------|---------| +| `json_parse(s)` | Parse Engram API responses | +| `json_stringify(v)` | Serialize values to JSON for API requests | +| `json_get_string(json, key)` | Extract string fields from node JSON | +| `json_get_int(json, key)` | Extract integer fields (counts, timestamps) | +| `json_get_float(json, key)` | Extract float fields (salience, weights) | +| `json_get_raw(json, key)` | Extract nested objects as raw JSON strings | + +### 5.2 Color/Terminal Builtins + +| Builtin | Used for | +|---------|---------| +| `color_bold(s)` | Section headers, labels | +| `color_dim(s)` | Timestamps, IDs, less important data | +| `color_green(s)` | Success states, high salience | +| `color_yellow(s)` | Warnings, medium salience | +| `color_cyan(s)` | URLs, relation names, special values | +| `color_red(s)` | Errors, low salience | + +### 5.3 String Formatting Builtins + +| Builtin | Signature | Description | +|---------|-----------|-------------| +| `str_pad_right(s, width, pad)` | Pad string to width on right | +| `str_pad_left(s, width, pad)` | Pad string to width on left | +| `format_float(f, decimals)` | Format float to N decimal places | +| `str_slice(s, start, end)` | Extract substring by character index | +| `str_len(s)` | String length in characters | + +--- + +## 6. Deployment + +### 6.1 Running the Studio + +```bash +# Connect to default local server +el run-file studio/studio.el + +# Connect to remote server +ENGRAM_URL=http://engram.example.com el run-file studio/studio.el + +# Save report to custom path +ENGRAM_REPORT=/var/log/engram-report.txt el run-file studio/studio.el +``` + +### 6.2 Running Tests + +```bash +el run-file test/language_features_test.el +el run-file test/field_test.el +ENGRAM_URL=http://localhost:8340 el run-file test/llm_test.el +``` + +--- + +## 7. Design Decisions + +### 7.1 Pure HTTP Integration + +engram-el uses HTTP exclusively. It does not use the lower-level `graph_compile` and `graph_traverse` VM builtins. This is by design: it demonstrates the HTTP API surface as the primary integration mechanism. The VM builtins are for tightly-integrated runtime code (the Neuron daemon); external tools use the HTTP API. + +### 7.2 Stateless Programs + +All engram-el programs are stateless — they read state from Engram on each run and write nothing back (the studio is read-only). This is the correct architecture for exploration tools: they observe the graph without mutating it. + +### 7.3 El as Application Language + +The studio's 788 lines of El demonstrate that El is a capable application language. It is not a configuration DSL or a scripting language for simple tasks. The studio handles: API communication, JSON parsing, recursive data rendering, ASCII art, ANSI color codes, file I/O, environment variable configuration, and complex string manipulation — all with El's native builtins, without imports. diff --git a/engram/src/server.el b/engram/src/server.el new file mode 100644 index 0000000..dc69f5d --- /dev/null +++ b/engram/src/server.el @@ -0,0 +1,293 @@ +// server.el — Engram HTTP server. +// +// Engram is the in-process graph store. The runtime owns the data; this +// file is the thin HTTP face. Every route maps to one or two engram_* +// builtins. There is no SQL, no db layer, no SQLite — the runtime IS the +// database. +// +// Built and linked with: +// elc src/server.el > server.c +// cc -std=c11 -O2 -lcurl -lpthread -o engram server.c el_runtime.c +// ./engram +// +// Configuration via environment: +// ENGRAM_BIND — host:port (default :8742) +// ENGRAM_API_KEY — bearer auth (optional) +// ENGRAM_DATA_DIR — snapshot location (default ~/.neuron/engram) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn parse_port(bind: String) -> Int { + // ":8742" → 8742; "0.0.0.0:8742" → 8742; bare "8742" → 8742 + let colon: Int = str_index_of(bind, ":") + if colon < 0 { + return str_to_int(bind) + } + let after: String = str_slice(bind, colon + 1, str_len(bind)) + return str_to_int(after) +} + +fn ok_json() -> String { + "{\"ok\":true}" +} + +fn err_json(msg: String) -> String { + "{\"error\":\"" + msg + "\"}" +} + +fn strip_query(path: String) -> String { + let q: Int = str_index_of(path, "?") + if q < 0 { return path } + str_slice(path, 0, q) +} + +fn query_param(path: String, key: String) -> String { + let q: Int = str_index_of(path, "?") + if q < 0 { return "" } + let qs: String = str_slice(path, q + 1, str_len(path)) + let needle: String = key + "=" + let pos: Int = str_index_of(qs, needle) + if pos < 0 { return "" } + let after: String = str_slice(qs, pos + str_len(needle), str_len(qs)) + let amp: Int = str_index_of(after, "&") + if amp < 0 { return after } + str_slice(after, 0, amp) +} + +fn query_int(path: String, key: String, default_val: Int) -> Int { + let v: String = query_param(path, key) + if str_eq(v, "") { return default_val } + str_to_int(v) +} + +// Extract last path segment after a known prefix: extract_id("/api/nodes/abc-123", "/api/nodes/") → "abc-123" +fn extract_id(path: String, prefix: String) -> String { + let clean: String = strip_query(path) + if !str_starts_with(clean, prefix) { return "" } + let after: String = str_slice(clean, str_len(prefix), str_len(clean)) + let slash: Int = str_index_of(after, "/") + if slash < 0 { return after } + str_slice(after, 0, slash) +} + +// ── Routes ──────────────────────────────────────────────────────────────────── + +fn route_stats(method: String, path: String, body: String) -> String { + engram_stats_json() +} + +fn route_create_node(method: String, path: String, body: String) -> String { + let content: String = json_get_string(body, "content") + let node_type: String = json_get_string(body, "node_type") + if str_eq(node_type, "") { let node_type = "Memory" } + let salience: Float = json_get_float(body, "salience") + if salience == 0.0 { let salience = 0.5 } + let id: String = engram_node(content, node_type, salience) + "{\"id\":\"" + id + "\",\"content\":\"" + content + "\",\"node_type\":\"" + node_type + "\"}" +} + +fn route_get_node(method: String, path: String, body: String) -> String { + let id: String = extract_id(path, "/api/nodes/") + if str_eq(id, "") { return err_json("missing id") } + return engram_get_node_json(id) +} + +fn route_scan_nodes(method: String, path: String, body: String) -> String { + let limit: Int = query_int(path, "limit", 50) + let offset: Int = query_int(path, "offset", 0) + return engram_scan_nodes_json(limit, offset) +} + +fn route_search(method: String, path: String, body: String) -> String { + let q: String = "" + if str_eq(method, "GET") { + let q = query_param(path, "q") + } else { + let q = json_get_string(body, "query") + } + let limit: Int = query_int(path, "limit", 20) + if limit == 0 { let limit = json_get_int(body, "limit") } + if limit == 0 { let limit = 20 } + return engram_search_json(q, limit) +} + +fn route_activate(method: String, path: String, body: String) -> String { + let q: String = "" + let depth: Int = 3 + if str_eq(method, "GET") { + let q = query_param(path, "q") + let depth = query_int(path, "depth", 3) + } else { + let q = json_get_string(body, "query") + let bd: Int = json_get_int(body, "depth") + if bd > 0 { let depth = bd } + } + return "{\"results\":" + engram_activate_json(q, depth) + "}" +} + +fn route_create_edge(method: String, path: String, body: String) -> String { + let from_id: String = json_get_string(body, "from_id") + let to_id: String = json_get_string(body, "to_id") + let relation: String = json_get_string(body, "relation") + if str_eq(relation, "") { let relation = "associates" } + let weight: Float = json_get_float(body, "weight") + if weight == 0.0 { let weight = 0.5 } + engram_connect(from_id, to_id, weight, relation) + "{\"ok\":true,\"from_id\":\"" + from_id + "\",\"to_id\":\"" + to_id + "\",\"relation\":\"" + relation + "\"}" +} + +fn route_neighbors(method: String, path: String, body: String) -> String { + let id: String = extract_id(path, "/api/neighbors/") + if str_eq(id, "") { return err_json("missing id") } + let depth: Int = query_int(path, "depth", 1) + return engram_neighbors_json(id, depth, "both") +} + +fn route_strengthen(method: String, path: String, body: String) -> String { + let id: String = json_get_string(body, "node_id") + if str_eq(id, "") { return err_json("missing node_id") } + engram_strengthen(id) + ok_json() +} + +fn route_forget(method: String, path: String, body: String) -> String { + let id: String = extract_id(path, "/api/nodes/") + if str_eq(id, "") { return err_json("missing id") } + engram_forget(id) + ok_json() +} + +fn route_save(method: String, path: String, body: String) -> String { + let p: String = json_get_string(body, "path") + if str_eq(p, "") { + let dir: String = env("ENGRAM_DATA_DIR") + if str_eq(dir, "") { let dir = "/tmp/engram" } + let p = dir + "/snapshot.json" + } + engram_save(p) + "{\"ok\":true,\"path\":\"" + p + "\"}" +} + +fn route_load(method: String, path: String, body: String) -> String { + let p: String = json_get_string(body, "path") + if str_eq(p, "") { + let dir: String = env("ENGRAM_DATA_DIR") + if str_eq(dir, "") { let dir = "/tmp/engram" } + let p = dir + "/snapshot.json" + } + engram_load(p) + ok_json() +} + +fn route_health(method: String, path: String, body: String) -> String { + "{\"status\":\"ok\",\"engine\":\"engram-runtime-native\"}" +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +fn check_auth_ok(method: String, body: String) -> Bool { + let key: String = env("ENGRAM_API_KEY") + if str_eq(key, "") { return true } + // Read-only methods don't require auth. Until http_serve surfaces + // request headers we can't accept a Bearer token cleanly; mutating + // requests must include "_auth": "" in the JSON body. + if str_eq(method, "GET") { return true } + let provided: String = json_get_string(body, "_auth") + if str_eq(provided, key) { return true } + return false +} + +// ── Dispatcher ──────────────────────────────────────────────────────────────── + +fn handle_request(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + + // Health is always reachable + if str_eq(method, "GET") { + if str_eq(clean, "/health") || str_eq(clean, "/") { + return route_health(method, path, body) + } + } + + // Auth (when ENGRAM_API_KEY is set) + if !check_auth_ok(method, body) { + return err_json("unauthorized") + } + + // Stats + if str_eq(method, "GET") && (str_eq(clean, "/api/stats") || str_eq(clean, "/stats")) { + return route_stats(method, path, body) + } + + // Nodes + if str_eq(method, "POST") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/nodes")) { + return route_create_node(method, path, body) + } + if str_eq(method, "GET") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/nodes")) { + return route_scan_nodes(method, path, body) + } + if str_eq(method, "GET") && str_starts_with(clean, "/api/nodes/") { + return route_get_node(method, path, body) + } + if str_eq(method, "DELETE") && str_starts_with(clean, "/api/nodes/") { + return route_forget(method, path, body) + } + + // Edges + if str_eq(method, "POST") && (str_eq(clean, "/api/edges") || str_eq(clean, "/edges")) { + return route_create_edge(method, path, body) + } + if str_eq(method, "GET") && str_starts_with(clean, "/api/neighbors/") { + return route_neighbors(method, path, body) + } + + // Activation + Search + if str_eq(method, "POST") && (str_eq(clean, "/api/activate") || str_eq(clean, "/activate")) { + return route_activate(method, path, body) + } + if str_eq(method, "GET") && str_starts_with(clean, "/api/activate") { + return route_activate(method, path, body) + } + if str_eq(method, "POST") && (str_eq(clean, "/api/search") || str_eq(clean, "/search")) { + return route_search(method, path, body) + } + if str_eq(method, "GET") && str_starts_with(clean, "/api/search") { + return route_search(method, path, body) + } + + // Strengthen + if str_eq(method, "POST") && (str_eq(clean, "/api/strengthen") || str_eq(clean, "/strengthen")) { + return route_strengthen(method, path, body) + } + + // Persistence + if str_eq(method, "POST") && (str_eq(clean, "/api/save") || str_eq(clean, "/save")) { + return route_save(method, path, body) + } + if str_eq(method, "POST") && (str_eq(clean, "/api/load") || str_eq(clean, "/load")) { + return route_load(method, path, body) + } + + "{\"error\":\"not found\",\"path\":\"" + clean + "\"}" +} + +// ── Entry ───────────────────────────────────────────────────────────────────── + +let bind_str: String = env("ENGRAM_BIND") +if str_eq(bind_str, "") { let bind_str = ":8742" } +let port: Int = parse_port(bind_str) + +// On startup, try to load any existing snapshot (best effort). +let data_dir: String = env("ENGRAM_DATA_DIR") +if str_eq(data_dir, "") { let data_dir = "/tmp/engram" } +let snapshot_path: String = data_dir + "/snapshot.json" +engram_load(snapshot_path) + +println("[engram] runtime-native graph engine") +println("[engram] data_dir=" + data_dir) +println("[engram] node_count=" + int_to_str(engram_node_count())) +println("[engram] edge_count=" + int_to_str(engram_edge_count())) +println("[engram] listening on " + int_to_str(port)) + +http_set_handler("handle_request") +http_serve(port, "handle_request")