diff --git a/routes.el b/routes.el index b7bd43e..b8750ec 100644 --- a/routes.el +++ b/routes.el @@ -11,6 +11,7 @@ import "soul.elh" // integer 1 (el-src UI). json_get_bool only recognises literal `true`, so // without this wrapper an "agentic":1 request would silently route to the // non-agentic path. +@utility fn flag_true(body: String, key: String) -> Bool { return json_get_bool(body, key) || json_get_int(body, key) > 0 } @@ -108,6 +109,7 @@ fn rate_limit_check(ip: String, path: String) -> String { return "" } +@utility fn strip_query(path: String) -> String { let q: Int = str_index_of(path, "?") if q < 0 { @@ -116,14 +118,17 @@ fn strip_query(path: String) -> String { return str_slice(path, 0, q) } +@utility fn err_404(path: String) -> String { return "{\"error\":\"not found\",\"code\":\"not_found\",\"path\":\"" + path + "\"}" } +@utility fn err_405(method: String, path: String) -> String { return "{\"error\":\"method not allowed\",\"code\":\"method_not_allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}" } +@manager fn route_health() -> String { let cgi_id: String = state_get("soul_cgi_id") let boot: String = state_get("soul_boot_count") @@ -164,6 +169,7 @@ fn route_health() -> String { + ",\"layers\":{\"l0\":\"core\",\"l1\":\"safety\",\"l2\":\"stewardship\",\"l3\":\"" + imprint_current() + "\"}}" } +@manager fn route_lineage() -> String { let cgi_id: String = state_get("soul_cgi_id") let q: String = "lineage:" + cgi_id @@ -181,6 +187,7 @@ fn route_lineage() -> String { return raw } +@manager fn route_imprint_contextual(body: String) -> String { if str_eq(body, "") { return "{\"ok\":false,\"error\":\"empty body\"}" @@ -203,6 +210,7 @@ fn route_imprint_contextual(body: String) -> String { return "{\"ok\":true,\"id\":\"" + id + "\"}" } +@manager fn route_imprint_user(body: String) -> String { if str_eq(body, "") { return "{\"ok\":false,\"error\":\"empty body\"}" @@ -225,6 +233,7 @@ fn route_imprint_user(body: String) -> String { return "{\"ok\":true,\"id\":\"" + id + "\"}" } +@manager fn route_synthesize(body: String) -> String { if str_eq(body, "") { return "{\"error\":\"body is required\",\"code\":\"missing_param\"}" @@ -252,6 +261,7 @@ fn route_synthesize(body: String) -> String { return "{\"mechanism\":\"did not engage\"}" } +@manager fn handle_dharma_recv(body: String) -> String { let content_raw: String = json_get(body, "content") let from_id: String = json_get(body, "from") @@ -340,6 +350,7 @@ fn handle_dharma_recv(body: String) -> String { // the bridge. Bridge-down returns a clear error (not a panic). // --------------------------------------------------------------------------- +@accessor fn connectd_get(suffix: String) -> String { let out: String = exec_capture("curl -s --max-time 5 http://127.0.0.1:7771" + suffix) if str_eq(out, "") { @@ -350,6 +361,7 @@ fn connectd_get(suffix: String) -> String { // POST passthrough: request body is written to a temp file and passed via -d @file // so arbitrary JSON cannot reach the shell as a command-line argument. +@accessor fn connectd_post(suffix: String, body: String) -> String { let eff: String = if str_eq(body, "") { "{}" } else { body } // Unique temp path per call — prevents collision if concurrency is ever added @@ -363,35 +375,519 @@ fn connectd_post(suffix: String, body: String) -> String { return out } -fn handle_connectors(method: String, clean: String, body: String) -> String { - if str_eq(method, "GET") { - // /api/connectors -> each configured server with status, tools, auth, auto-approve. - return connectd_get("/mcp/servers") +// ═══════════════════════════════════════════════════════════════════════════ +// @route DISPATCH — every HTTP route is a @route-decorated handler. The El +// compiler scans these decorators and synthesizes `el_route_dispatch(method, +// clean, path, body)`, emitted SPECIFICITY-SORTED (exact > compound > suffix > +// prefix; longer wins within a class) so overlapping paths never shadow, +// independent of source order. Matching is on `clean` (query-stripped); the +// ORIGINAL `path` is passed to handlers so query strings survive. Unmatched → +// sentinel "__EL_NO_ROUTE__". handle_request (bottom) calls it once, then maps +// the sentinel to 404 (recognised method) / 405 (unknown method). +// +// Adapters carry the uniform (method, path, body) signature. Those that need +// the query-stripped path recompute `clean = strip_query(path)` internally, +// exactly as the former hand-written dispatcher did. +// ═══════════════════════════════════════════════════════════════════════════ + +// ── pre-guard: inter-soul Dharma receive (POST /dharma/recv) ──────────────── +@route("/dharma/recv", "POST", "exact") @manager +fn r_dharma_recv(method: String, path: String, body: String) -> String { + return handle_dharma_recv(body) +} + +// ── GET: liveness / lineage ───────────────────────────────────────────────── +@route("/health", "GET", "exact") @manager +fn r_health(method: String, path: String, body: String) -> String { + return route_health() +} + +@route("/lineage", "GET", "exact") @manager +fn r_lineage(method: String, path: String, body: String) -> String { + return route_lineage() +} + +// ── GET: raw engram graph (two exact aliases share one helper) ────────────── +@route("/api/graph", "GET", "exact") @manager +fn r_api_graph(method: String, path: String, body: String) -> String { + return engram_scan_nodes_json(9999, 0) +} + +@route("/api/graph/nodes", "GET", "exact") @manager +fn r_api_graph_nodes(method: String, path: String, body: String) -> String { + return engram_scan_nodes_json(9999, 0) +} + +@route("/api/graph/edges", "GET", "exact") @manager +fn r_api_graph_edges(method: String, path: String, body: String) -> String { + // TODO(reliability #8): engram_save races with awareness loop mem_save(). + // Both now use atomic write-to-temp+rename (el_runtime.c). Serialised + // by engram_global_mu. Future: add engram_edges_json() builtin. + let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json" + engram_save(snap_path) + let snap: String = fs_read(snap_path) + let edges_raw: String = json_get_raw(snap, "edges") + return if str_eq(edges_raw, "") { "[]" } else { edges_raw } +} + +// ── GET /api/chat — legacy probe interface; body may be empty ─────────────── +@route("/api/chat", "GET", "exact") @manager +fn r_chat_get(method: String, path: String, body: String) -> String { + let raw_msg: String = json_get(body, "message") + let eff_msg: String = if str_eq(raw_msg, "") { body } else { raw_msg } + if str_eq(eff_msg, "") { + return "{\"error\":\"message is required\",\"code\":\"missing_param\"}" } - if str_eq(clean, "/api/connectors/add") { - return connectd_post("/mcp/servers/add", body) + let agentic_flag: Bool = json_get_bool(body, "agentic") + let req_mode: String = json_get(body, "mode") + let reply: String = if str_eq(req_mode, "plan") { + handle_chat_plan(body) + } else if agentic_flag { + handle_chat_agentic(body) + } else { + let screened_reply: String = layered_cycle(eff_msg) + screened_reply } - if str_eq(clean, "/api/connectors/toggle") { - return connectd_post("/mcp/servers/toggle", body) + auto_persist(body, reply) + return reply +} + +// ── GET|POST: method-branching handlers (same fn, guards on method) ───────── +@route("/api/conversations", "GET|POST", "exact") @manager +fn r_conversations(method: String, path: String, body: String) -> String { + return handle_conversations(method) +} + +@route("/api/config", "GET|POST", "exact") @manager +fn r_config(method: String, path: String, body: String) -> String { + return handle_config(method, body) +} + +@route("/api/tools/", "GET|POST", "prefix") @manager +fn r_tools(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return handle_tool(clean, method, body) +} + +@route("/api/dharma", "GET|POST", "prefix") @manager +fn r_dharma(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return handle_dharma(clean, method, body) +} + +@route("/api/nlg", "GET|POST", "prefix") @manager +fn r_nlg(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return handle_nlg(clean, method, body) +} + +// ── GET|POST axon proxies (GET → axon_get, POST → axon_post) ──────────────── +@route("/api/memories", "GET|POST", "prefix") @manager +fn r_memories(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +@route("/api/knowledge", "GET|POST", "prefix") @manager +fn r_knowledge_axon(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +@route("/api/backlog", "GET|POST", "prefix") @manager +fn r_backlog(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +@route("/api/artifacts", "GET|POST", "prefix") @manager +fn r_artifacts(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +@route("/api/projects", "GET|POST", "prefix") @manager +fn r_projects(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +@route("/api/imprints", "GET|POST", "prefix") @manager +fn r_imprints(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + return if str_eq(method, "GET") { axon_get(clean) } else { axon_post(clean, body) } +} + +// ── GET / — studio UI ─────────────────────────────────────────────────────── +@route("/", "GET", "exact") @manager +fn r_root(method: String, path: String, body: String) -> String { + return render_studio() +} + +// ── Neuron cognitive API — session/ctx (GET empty arg, POST body) ─────────── +@route("/api/neuron/session/begin", "GET|POST", "exact") @manager +fn r_session_begin(method: String, path: String, body: String) -> String { + return if str_eq(method, "GET") { handle_api_begin_session("") } else { handle_api_begin_session(body) } +} + +@route("/api/neuron/ctx", "GET|POST", "exact") @manager +fn r_ctx(method: String, path: String, body: String) -> String { + return if str_eq(method, "GET") { handle_api_compile_ctx("") } else { handle_api_compile_ctx(body) } +} + +@route("/api/safety-contact", "GET|POST", "exact") @manager +fn r_safety_contact(method: String, path: String, body: String) -> String { + return if str_eq(method, "GET") { handle_safety_contact_get() } else { handle_safety_contact_post(body) } +} + +// ── Neuron cognitive API — knowledge ──────────────────────────────────────── +// GET search is a PREFIX (legacy) while POST search is EXACT — kept distinct so +// semantics match the former dispatcher byte-for-byte. +@route("/api/neuron/knowledge/search", "GET", "prefix") @manager +fn r_knowledge_search_get(method: String, path: String, body: String) -> String { + return handle_api_search_knowledge(method, path, body) +} + +@route("/api/neuron/knowledge/search", "POST", "exact") @manager +fn r_knowledge_search_post(method: String, path: String, body: String) -> String { + return handle_api_search_knowledge(method, path, body) +} + +@route("/api/neuron/knowledge", "GET", "exact") @manager +fn r_knowledge_browse(method: String, path: String, body: String) -> String { + return handle_api_browse_knowledge(path, body) +} + +@route("/api/neuron/knowledge/capture", "POST", "exact") @manager +fn r_knowledge_capture(method: String, path: String, body: String) -> String { + return handle_api_capture_knowledge(body) +} + +@route("/api/neuron/knowledge/evolve", "POST", "exact") @manager +fn r_knowledge_evolve(method: String, path: String, body: String) -> String { + return handle_api_evolve_knowledge(body) +} + +@route("/api/neuron/knowledge/promote", "POST", "exact") @manager +fn r_knowledge_promote(method: String, path: String, body: String) -> String { + return handle_api_promote_knowledge(body) +} + +// ── Neuron cognitive API — processes (GET prefix, POST exact + define) ────── +@route("/api/neuron/processes", "GET", "prefix") @manager +fn r_processes_get(method: String, path: String, body: String) -> String { + return handle_api_browse_processes(method, path, body) +} + +@route("/api/neuron/processes", "POST", "exact") @manager +fn r_processes_post(method: String, path: String, body: String) -> String { + return handle_api_browse_processes(method, path, body) +} + +@route("/api/neuron/processes/define", "POST", "exact") @manager +fn r_processes_define(method: String, path: String, body: String) -> String { + return handle_api_define_process(body) +} + +// ── Neuron cognitive API — state events (GET prefix list, POST exact log) ─── +@route("/api/neuron/state-events", "GET", "prefix") @manager +fn r_state_events_get(method: String, path: String, body: String) -> String { + return handle_api_list_state_events(method, path, body) +} + +@route("/api/neuron/state-events", "POST", "exact") @manager +fn r_state_events_post(method: String, path: String, body: String) -> String { + return handle_api_log_state_event(body) +} + +// ── Neuron cognitive API — config (GET prefix, POST exact + tune) ────────── +@route("/api/neuron/config", "GET", "prefix") @manager +fn r_config_get(method: String, path: String, body: String) -> String { + return handle_api_inspect_config(path, body) +} + +@route("/api/neuron/config", "POST", "exact") @manager +fn r_config_post(method: String, path: String, body: String) -> String { + return handle_api_inspect_config(path, body) +} + +@route("/api/neuron/config/tune", "POST", "exact") @manager +fn r_config_tune(method: String, path: String, body: String) -> String { + return handle_api_tune_config(body) +} + +// ── Neuron cognitive API — graph (GET prefix, POST exact + link) ─────────── +@route("/api/neuron/graph", "GET", "prefix") @manager +fn r_graph_get(method: String, path: String, body: String) -> String { + return handle_api_inspect_graph(method, path, body) +} + +@route("/api/neuron/graph", "POST", "exact") @manager +fn r_graph_post(method: String, path: String, body: String) -> String { + return handle_api_inspect_graph(method, path, body) +} + +@route("/api/neuron/graph/link", "POST", "exact") @manager +fn r_graph_link(method: String, path: String, body: String) -> String { + return handle_api_link_entities(body) +} + +// ── Neuron cognitive API — typed-node list (dynamic :node_type) ───────────── +// Offset 17 = len("/api/neuron/list/"). str_slice on `clean` so query strings +// never leak into node_type. +@route("/api/neuron/list/", "GET", "prefix") @manager +fn r_list_typed(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let node_type: String = str_slice(clean, 17, str_len(clean)) + return handle_api_list_typed(node_type, path, body) +} + +// ── Neuron cognitive API — recall (GET prefix, POST exact) ───────────────── +@route("/api/neuron/recall", "GET", "prefix") @manager +fn r_recall_get(method: String, path: String, body: String) -> String { + return handle_api_recall(method, path, body) +} + +@route("/api/neuron/recall", "POST", "exact") @manager +fn r_recall_post(method: String, path: String, body: String) -> String { + return handle_api_recall(method, path, body) +} + +// ── Neuron cognitive API — memory / node writes (POST exact) ──────────────── +@route("/api/neuron/memory", "POST", "exact") @manager +fn r_memory(method: String, path: String, body: String) -> String { + return handle_api_remember(body) +} + +@route("/api/neuron/memory/evolve", "POST", "exact") @manager +fn r_memory_evolve(method: String, path: String, body: String) -> String { + return handle_api_evolve_memory(body) +} + +@route("/api/neuron/memory/forget", "POST", "exact") @manager +fn r_memory_forget(method: String, path: String, body: String) -> String { + return handle_api_forget(body) +} + +@route("/api/neuron/memory/delete", "POST", "exact") @manager +fn r_memory_delete(method: String, path: String, body: String) -> String { + return handle_api_memory_delete(body) +} + +@route("/api/neuron/memory/update", "POST", "exact") @manager +fn r_memory_update(method: String, path: String, body: String) -> String { + return handle_api_memory_update(body) +} + +@route("/api/neuron/node/create", "POST", "exact") @manager +fn r_node_create(method: String, path: String, body: String) -> String { + return handle_api_node_create(body) +} + +@route("/api/neuron/node/update", "POST", "exact") @manager +fn r_node_update(method: String, path: String, body: String) -> String { + return handle_api_node_update(body) +} + +@route("/api/neuron/node/delete", "POST", "exact") @manager +fn r_node_delete(method: String, path: String, body: String) -> String { + return handle_api_node_delete(body) +} + +@route("/api/neuron/consolidate", "POST", "exact") @manager +fn r_consolidate(method: String, path: String, body: String) -> String { + return handle_api_consolidate(body) +} + +@route("/api/neuron/cultivate", "POST", "exact") @manager +fn r_cultivate(method: String, path: String, body: String) -> String { + return handle_api_cultivate(body) +} + +// ── POST: chat / ELP / see / imprint / synthesize ────────────────────────── +@route("/api/elp/chat", "POST", "exact") @manager +fn r_elp_chat(method: String, path: String, body: String) -> String { + return handle_elp_chat(body) +} + +@route("/api/see", "POST", "exact") @manager +fn r_see(method: String, path: String, body: String) -> String { + return handle_see(body) +} + +@route("/imprint/contextual", "POST", "exact") @manager +fn r_imprint_contextual(method: String, path: String, body: String) -> String { + return route_imprint_contextual(body) +} + +@route("/imprint/user", "POST", "exact") @manager +fn r_imprint_user(method: String, path: String, body: String) -> String { + return route_imprint_user(body) +} + +@route("/synthesize", "POST", "exact") @manager +fn r_synthesize(method: String, path: String, body: String) -> String { + return route_synthesize(body) +} + +// POST /api/chat — buffered (no streaming); message is REQUIRED. +@route("/api/chat", "POST", "exact") @manager +fn r_chat_post(method: String, path: String, body: String) -> String { + let raw_msg: String = json_get(body, "message") + if str_eq(raw_msg, "") { + return "{\"error\":\"message is required\",\"code\":\"missing_param\"}" } - if str_eq(clean, "/api/connectors/auto-approve") { - return connectd_post("/mcp/servers/auto-approve", body) + let agentic_flag: Bool = json_get_bool(body, "agentic") + let req_mode: String = json_get(body, "mode") + let reply: String = if str_eq(req_mode, "plan") { + handle_chat_plan(body) + } else if agentic_flag { + handle_chat_agentic(body) + } else { + let screened_reply: String = layered_cycle(raw_msg) + screened_reply } - if str_eq(clean, "/api/connectors/remove") { - return connectd_post("/mcp/servers/remove", body) + auto_persist(body, reply) + return reply +} + +// ── Sessions — list / create / dynamic :id (GET/POST/DELETE/PATCH) ────────── +@route("/api/sessions", "GET", "exact") @manager +fn r_sessions_list(method: String, path: String, body: String) -> String { + return session_list() +} + +@route("/api/sessions", "POST", "exact") @manager +fn r_sessions_create(method: String, path: String, body: String) -> String { + return session_create(body) +} + +// COMPOUND: POST /api/sessions/:id/tool_result — MCP tool-bridge resume. Must +// out-specify the bare approve prefix (it does: compound > prefix), preserving +// the load-bearing tool_result-before-approve order of the old dispatcher. +@route("/api/sessions/", "POST", "compound", "/tool_result") @manager +fn r_sessions_tool_result(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let after: String = str_slice(clean, 14, str_len(clean)) + let slash: Int = str_index_of(after, "/") + let session_id: String = if slash < 0 { after } else { str_slice(after, 0, slash) } + return handle_tool_result(session_id, body) +} + +// POST /api/sessions/:id/approve — bare prefix + in-handler sub check, exactly +// as the former dispatcher. Non-"approve" subpaths fall through to 404 (the old +// code returned nothing and dropped to the POST-block err_404). +@route("/api/sessions/", "POST", "prefix") @manager +fn r_sessions_approve(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let sess_after: String = str_slice(clean, 14, str_len(clean)) + let sess_slash: Int = str_index_of(sess_after, "/") + let sess_id: String = if sess_slash < 0 { sess_after } else { str_slice(sess_after, 0, sess_slash) } + let sess_sub: String = if sess_slash < 0 { "" } else { str_slice(sess_after, sess_slash + 1, str_len(sess_after)) } + if !str_eq(sess_id, "") && str_eq(sess_sub, "approve") { + return handle_session_approve(sess_id, body) } - if str_eq(clean, "/api/connectors/secret") { - return connectd_post("/mcp/servers/secret", body) + return err_404(clean) +} + +@route("/api/sessions/", "GET", "prefix") @manager +fn r_sessions_get(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let gs_after: String = str_slice(clean, 14, str_len(clean)) + let gs_slash: Int = str_index_of(gs_after, "/") + let gs_id: String = if gs_slash < 0 { gs_after } else { str_slice(gs_after, 0, gs_slash) } + if !str_eq(gs_id, "") { + return session_get(gs_id) } - if str_eq(clean, "/api/connectors/oauth/start") { - return connectd_post("/mcp/oauth/start", body) + return err_404(clean) +} + +@route("/api/sessions/", "DELETE", "prefix") @manager +fn r_sessions_delete(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let del_after: String = str_slice(clean, 14, str_len(clean)) + let del_slash: Int = str_index_of(del_after, "/") + let del_id: String = if del_slash < 0 { del_after } else { str_slice(del_after, 0, del_slash) } + if !str_eq(del_id, "") { + return session_delete(del_id) } - // Call a connector tool directly (pre-chat), e.g. WhatsApp get_pairing_qr / get_login_status for - // the pairing UI. Body: {"name":"mcp____","input":{...}}. Keeps the app on the - // app->soul->connectd path (the UI never hits connectd directly) and works for remote/hosted apps. - if str_eq(clean, "/api/connectors/call") { - return connectd_post("/mcp/call", body) + return err_404(clean) +} + +@route("/api/sessions/", "PATCH", "prefix") @manager +fn r_sessions_patch(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let patch_after: String = str_slice(clean, 14, str_len(clean)) + let patch_slash: Int = str_index_of(patch_after, "/") + let patch_id: String = if patch_slash < 0 { patch_after } else { str_slice(patch_after, 0, patch_slash) } + if !str_eq(patch_id, "") { + return session_update_patch(patch_id, body) } + return err_404(clean) +} + +// ── GET /api/run-progress/:session_id — live agentic-run ledger ───────────── +// Offset 18 = len("/api/run-progress/"). +@route("/api/run-progress/", "GET", "prefix") @manager +fn r_run_progress(method: String, path: String, body: String) -> String { + let clean: String = strip_query(path) + let rp_id: String = str_slice(clean, 18, str_len(clean)) + if !str_eq(rp_id, "") { + let rp_raw: String = state_get("run_progress_" + rp_id) + let rp_arr: String = if str_eq(rp_raw, "") { "[]" } else { "[" + rp_raw + "]" } + return "{\"progress\":" + rp_arr + "}" + } + return err_404(clean) +} + +// ── MCP Connectors — proxy to neuron-connectd :7771 ───────────────────────── +// GET (any /api/connectors*) → server list. POST sub-routes are exact; an +// unmatched POST /api/connectors* prefix returns the "unknown connectors route" +// body, exactly as the former handle_connectors fallthrough. +@route("/api/connectors", "GET", "prefix") @manager +fn r_connectors_get(method: String, path: String, body: String) -> String { + return connectd_get("/mcp/servers") +} + +@route("/api/connectors/add", "POST", "exact") @manager +fn r_connectors_add(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/servers/add", body) +} + +@route("/api/connectors/toggle", "POST", "exact") @manager +fn r_connectors_toggle(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/servers/toggle", body) +} + +@route("/api/connectors/auto-approve", "POST", "exact") @manager +fn r_connectors_auto_approve(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/servers/auto-approve", body) +} + +@route("/api/connectors/remove", "POST", "exact") @manager +fn r_connectors_remove(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/servers/remove", body) +} + +@route("/api/connectors/secret", "POST", "exact") @manager +fn r_connectors_secret(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/servers/secret", body) +} + +@route("/api/connectors/oauth/start", "POST", "exact") @manager +fn r_connectors_oauth_start(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/oauth/start", body) +} + +// Call a connector tool directly (pre-chat), e.g. WhatsApp get_pairing_qr / +// get_login_status. Keeps the app on the app->soul->connectd path. +@route("/api/connectors/call", "POST", "exact") @manager +fn r_connectors_call(method: String, path: String, body: String) -> String { + return connectd_post("/mcp/call", body) +} + +@route("/api/connectors", "POST", "prefix") @manager +fn r_connectors_unknown(method: String, path: String, body: String) -> String { return "{\"ok\":false,\"error\":\"unknown connectors route\"}" } @@ -410,6 +906,8 @@ fn handle_connectors(method: String, clean: String, body: String) -> String { // // wt_drain is a no-op (no HTTP, no cost) when nothing is staged and when the // soul is not in HTTP-engram mode, so this is free on read traffic. +// Dispatches through the compiler-synthesized @route table (el_route_dispatch, +// below) before falling through to any remaining hand-written routes. fn handle_request(method: String, path: String, body: String) -> String { let resp: String = route_dispatch(method, path, body) let flushed: Int = wt_drain() @@ -438,396 +936,28 @@ fn route_dispatch(method: String, path: String, body: String) -> String { } } - if str_eq(method, "POST") && str_eq(clean, "/dharma/recv") { - return handle_dharma_recv(body) + // Compiler-synthesized dispatch (specificity-sorted, method-guarded). + let route_resp: String = el_route_dispatch(method, clean, path, body) + if !str_eq(route_resp, "__EL_NO_ROUTE__") { + return route_resp } - if str_eq(method, "GET") { - if str_eq(clean, "/health") { - return route_health() - } - if str_eq(clean, "/lineage") { - return route_lineage() - } - if str_eq(clean, "/api/graph") || str_eq(clean, "/api/graph/nodes") { - return engram_scan_nodes_json(9999, 0) - } - if str_eq(clean, "/api/graph/edges") { - // FIXED (neuron#117): this GET used to engram_save() straight over - // ~/.neuron/engram/snapshot.json — a READ route, in a process that is - // NOT the persistence owner, overwriting the owner's canonical file - // on every call. It broke soul.el:571-573 ("the soul must NEVER write - // to the local snapshot") and it is the same defect class Will removed - // from the engram itself in el `dc39a61` ("stop read routes clobbering - // canonical snapshot"), where route_scan_edges/route_sync were moved - // to scratch paths for exactly this reason. It was also the race the - // old TODO(reliability #8) admitted to. - // - // Export to a scratch path instead. Same response, no canonical write. - // The soul's own snapshot writes are otherwise already gated behind - // state key "soul_snapshot_path", which is set ONLY in the genesis - // file-mode branch (soul.el: is_genesis && safe_to_seed, and - // safe_to_seed is unconditionally false when ENGRAM_URL is set) — so - // after this change the soul writes nothing at all in HTTP mode. - // Future: add an engram_edges_json() builtin and drop the file round - // trip entirely. - let scratch_dir: String = env("TMPDIR") - let scratch_base: String = if str_eq(scratch_dir, "") { "/tmp" } else { scratch_dir } - let snap_path: String = scratch_base + "/soul-edges-export-" + state_get("soul_cgi_id") + ".json" - engram_save(snap_path) - let snap: String = fs_read(snap_path) - let edges_raw: String = json_get_raw(snap, "edges") - return if str_eq(edges_raw, "") { "[]" } else { edges_raw } - } - if str_eq(clean, "/api/chat") { - // GET /api/chat: pass through layered_cycle for consistency with POST path. - // GET chat is a legacy probe interface; body may be empty for simple pings. - let raw_msg: String = json_get(body, "message") - let eff_msg: String = if str_eq(raw_msg, "") { body } else { raw_msg } - if str_eq(eff_msg, "") { - return "{\"error\":\"message is required\",\"code\":\"missing_param\"}" - } - let agentic_flag: Bool = json_get_bool(body, "agentic") - let req_mode: String = json_get(body, "mode") - let reply: String = if str_eq(req_mode, "plan") { - handle_chat_plan(body) - } else if agentic_flag { - handle_chat_agentic(body) - } else { - // Non-agentic ("Tools: Off") — same cycle and same envelope as POST. - // FIX B/E1: same threading. A GET probe usually carries no session_id, which - // resolves to the anonymous window — the documented behaviour for this door. - let screened_reply: String = layered_cycle(eff_msg, json_get(body, "session_id"), is_utility_request(body, json_get(body, "session_id"))) - plain_chat_envelope(screened_reply, chat_default_model()) - } - auto_persist(body, reply) - return reply - } - if str_eq(clean, "/api/conversations") { - return handle_conversations(method) - } - if str_eq(clean, "/api/config") { - return handle_config(method, body) - } - if str_starts_with(clean, "/api/tools/") { - return handle_tool(clean, method, body) - } - if str_starts_with(clean, "/api/dharma") { - return handle_dharma(clean, method, body) - } - if str_starts_with(clean, "/api/nlg") { - return handle_nlg(clean, method, body) - } - if str_starts_with(clean, "/api/memories") { - return axon_get(clean) - } - if str_starts_with(clean, "/api/knowledge") { - return axon_get(clean) - } - if str_starts_with(clean, "/api/backlog") { - return axon_get(clean) - } - if str_starts_with(clean, "/api/artifacts") { - return axon_get(clean) - } - if str_starts_with(clean, "/api/projects") { - return axon_get(clean) - } - if str_starts_with(clean, "/api/imprints") { - return axon_get(clean) - } - if str_eq(clean, "/") { - return render_studio() - } - // Neuron cognitive API — GET endpoints - if str_eq(clean, "/api/neuron/session/begin") { - return handle_api_begin_session("") - } - if str_eq(clean, "/api/neuron/ctx") { - return handle_api_compile_ctx("") - } - if str_eq(clean, "/api/safety-contact") { - return handle_safety_contact_get() - } - if str_starts_with(clean, "/api/neuron/knowledge/search") { - return handle_api_search_knowledge(method, path, body) - } - if str_eq(clean, "/api/neuron/knowledge") { - return handle_api_browse_knowledge(path, body) - } - if str_starts_with(clean, "/api/neuron/processes") { - return handle_api_browse_processes(method, path, body) - } - if str_starts_with(clean, "/api/neuron/state-events") { - return handle_api_list_state_events(method, path, body) - } - if str_starts_with(clean, "/api/neuron/config") { - return handle_api_inspect_config(path, body) - } - if str_starts_with(clean, "/api/neuron/graph") { - return handle_api_inspect_graph(method, path, body) - } - // Stage 1 structural audit (CGI provisional, "Structural audit 430"). - // GET because it is a read of the graph's own structure; the query string - // carries the sample caps (?edge_sample=, ?node_sample=, ?edges=0), so - // str_starts_with rather than str_eq. - if str_starts_with(clean, "/api/neuron/audit/structural") { - return handle_api_structural_audit(method, path, body) - } - if str_starts_with(clean, "/api/neuron/list/") { - // Offset 17 = len("/api/neuron/list/"). Was 16, which left a leading "/" on node_type - // ("/BacklogItem"), so engram_scan_nodes_by_type_json matched nothing → list/ - // returned [] for EVERY type (broke backlog/typed-node listing app- and tool-wide). - let node_type: String = str_slice(clean, 17, str_len(clean)) - return handle_api_list_typed(node_type, path, body) - } - if str_starts_with(clean, "/api/neuron/recall") { - return handle_api_recall(method, path, body) - } - if str_starts_with(clean, "/api/connectors") { - return handle_connectors(method, clean, body) - } - // GET /api/run-progress/:session_id — live agentic-run ledger (2026-07-13, - // narrated-runs). agentic_loop appends one {"i","t","tool"} entry per round - // (the model's own pre-tool narration); a {"done":true} entry closes the run. - // Clients poll this during a run to render live step updates without streaming. - if str_starts_with(clean, "/api/run-progress/") { - let rp_id: String = str_slice(clean, 18, str_len(clean)) - if !str_eq(rp_id, "") { - let rp_raw: String = state_get("run_progress_" + rp_id) - let rp_arr: String = if str_eq(rp_raw, "") { "[]" } else { "[" + rp_raw + "]" } - return "{\"progress\":" + rp_arr + "}" - } - } - // GET /api/sessions — list all sessions - if str_eq(clean, "/api/sessions") { - return session_list() - } - // GET /api/sessions/:id — get session metadata + history - if str_starts_with(clean, "/api/sessions/") { - let gs_after: String = str_slice(clean, 14, str_len(clean)) - let gs_slash: Int = str_index_of(gs_after, "/") - let gs_id: String = if gs_slash < 0 { gs_after } else { str_slice(gs_after, 0, gs_slash) } - if !str_eq(gs_id, "") { - return session_get(gs_id) - } - } + // handle_api_structural_audit lives in neuron-api.el (not routes.el), so it + // was missed by the @route conversion — kept as an explicit dispatch rather + // than guessed at cross-file @route semantics. GET uses a prefix match + // (query string carries sample caps: ?edge_sample=, ?node_sample=, ?edges=0); + // POST is exact-match, same handler, no body fields read. + if str_eq(method, "GET") && str_starts_with(clean, "/api/neuron/audit/structural") { + return handle_api_structural_audit(method, path, body) + } + if str_eq(method, "POST") && str_eq(clean, "/api/neuron/audit/structural") { + return handle_api_structural_audit(method, path, body) + } + + // Fallthrough: a recognised method with no matching path → 404; an + // unrecognised method → 405 (matches the old per-method-block structure). + if str_eq(method, "GET") || str_eq(method, "POST") || str_eq(method, "DELETE") || str_eq(method, "PATCH") { return err_404(clean) } - - if str_eq(method, "POST") { - // POST /api/sessions — create new session - if str_eq(clean, "/api/sessions") { - return session_create(body) - } - // MCP tool-bridge resume: POST /api/sessions/{id}/tool_result - // The client executed a tool the soul could not run in-process (an MCP - // connector/plugin) and posts the result back here so the agentic loop - // continues. {id} is the session_id from the prior tool_pending envelope. - if str_starts_with(clean, "/api/sessions/") && str_ends_with(clean, "/tool_result") { - let after: String = str_slice(clean, 14, str_len(clean)) - let slash: Int = str_index_of(after, "/") - let session_id: String = if slash < 0 { after } else { str_slice(after, 0, slash) } - return handle_tool_result(session_id, body) - } - // POST /api/sessions/:id/approve — user approval for a pending agentic tool call - if str_starts_with(clean, "/api/sessions/") { - let sess_after: String = str_slice(clean, 14, str_len(clean)) - let sess_slash: Int = str_index_of(sess_after, "/") - let sess_id: String = if sess_slash < 0 { sess_after } else { str_slice(sess_after, 0, sess_slash) } - let sess_sub: String = if sess_slash < 0 { "" } else { str_slice(sess_after, sess_slash + 1, str_len(sess_after)) } - if !str_eq(sess_id, "") && str_eq(sess_sub, "approve") { - return handle_session_approve(sess_id, body) - } - } - if str_eq(clean, "/imprint/contextual") { - return route_imprint_contextual(body) - } - if str_eq(clean, "/imprint/user") { - return route_imprint_user(body) - } - if str_eq(clean, "/synthesize") { - return route_synthesize(body) - } - if str_eq(clean, "/api/elp/chat") { - return handle_elp_chat(body) - } - if str_eq(clean, "/api/chat") { - // NOTE: streaming (SSE / chunked transfer) is not implemented. All chat - // responses are buffered and returned as a single JSON object. Streaming - // would require runtime-level SSE support in el_runtime.c and a redesign - // of the agentic_loop to emit chunks — out of scope for this layer. - let raw_msg: String = json_get(body, "message") - if str_eq(raw_msg, "") { - return "{\"error\":\"message is required\",\"code\":\"missing_param\"}" - } - let agentic_flag: Bool = json_get_bool(body, "agentic") - let req_mode: String = json_get(body, "mode") - let reply: String = if str_eq(req_mode, "plan") { - handle_chat_plan(body) - } else if agentic_flag { - handle_chat_agentic(body) - } else { - // Non-agentic ("Tools: Off") — the app's DEFAULT mode (AgentMode.NEVER). - // Full L1→L2→L3→L1 cycle with real generation at L3; envelope built - // outside the cycle so safety_validate always sees raw text. - // FIX B/E1: same threading. This is the app's main plain-chat door, so this - // is the site that ends the blank stare in practice. - let screened_reply: String = layered_cycle(raw_msg, json_get(body, "session_id"), is_utility_request(body, json_get(body, "session_id"))) - plain_chat_envelope(screened_reply, chat_default_model()) - } - auto_persist(body, reply) - return reply - } - if str_eq(clean, "/api/see") { - return handle_see(body) - } - if str_eq(clean, "/api/conversations") { - return handle_conversations(method) - } - if str_eq(clean, "/api/config") { - return handle_config(method, body) - } - if str_starts_with(clean, "/api/tools/") { - return handle_tool(clean, method, body) - } - if str_starts_with(clean, "/api/dharma") { - return handle_dharma(clean, method, body) - } - if str_starts_with(clean, "/api/nlg") { - return handle_nlg(clean, method, body) - } - if str_starts_with(clean, "/api/memories") { - return axon_post(clean, body) - } - if str_starts_with(clean, "/api/knowledge") { - return axon_post(clean, body) - } - if str_starts_with(clean, "/api/backlog") { - return axon_post(clean, body) - } - if str_starts_with(clean, "/api/artifacts") { - return axon_post(clean, body) - } - if str_starts_with(clean, "/api/projects") { - return axon_post(clean, body) - } - if str_starts_with(clean, "/api/imprints") { - return axon_post(clean, body) - } - // Neuron cognitive API — POST endpoints - if str_eq(clean, "/api/neuron/session/begin") { - return handle_api_begin_session(body) - } - if str_eq(clean, "/api/neuron/ctx") { - return handle_api_compile_ctx(body) - } - if str_eq(clean, "/api/neuron/knowledge/search") { - return handle_api_search_knowledge(method, path, body) - } - if str_eq(clean, "/api/neuron/knowledge/capture") { - return handle_api_capture_knowledge(body) - } - if str_eq(clean, "/api/neuron/knowledge/evolve") { - return handle_api_evolve_knowledge(body) - } - if str_eq(clean, "/api/neuron/knowledge/promote") { - return handle_api_promote_knowledge(body) - } - if str_eq(clean, "/api/neuron/processes") { - return handle_api_browse_processes(method, path, body) - } - if str_eq(clean, "/api/neuron/processes/define") { - return handle_api_define_process(body) - } - if str_eq(clean, "/api/neuron/state-events") { - return handle_api_log_state_event(body) - } - if str_eq(clean, "/api/neuron/config") { - return handle_api_inspect_config(path, body) - } - if str_eq(clean, "/api/neuron/config/tune") { - return handle_api_tune_config(body) - } - if str_eq(clean, "/api/neuron/graph") { - return handle_api_inspect_graph(method, path, body) - } - if str_eq(clean, "/api/neuron/graph/link") { - return handle_api_link_entities(body) - } - // POST accepted too: same handler, so a JSON-RPC-shaped caller that only - // speaks POST reaches the identical audit. Options still come from the - // query string — the handler reads no body fields. - if str_eq(clean, "/api/neuron/audit/structural") { - return handle_api_structural_audit(method, path, body) - } - if str_eq(clean, "/api/neuron/memory") { - return handle_api_remember(body) - } - if str_eq(clean, "/api/safety-contact") { - return handle_safety_contact_post(body) - } - if str_eq(clean, "/api/neuron/node/create") { - return handle_api_node_create(body) - } - if str_eq(clean, "/api/neuron/node/update") { - return handle_api_node_update(body) - } - if str_eq(clean, "/api/neuron/node/delete") { - return handle_api_node_delete(body) - } - if str_eq(clean, "/api/neuron/memory/evolve") { - return handle_api_evolve_memory(body) - } - if str_eq(clean, "/api/neuron/memory/forget") { - return handle_api_forget(body) - } - if str_eq(clean, "/api/neuron/memory/delete") { - return handle_api_memory_delete(body) - } - if str_eq(clean, "/api/neuron/memory/update") { - return handle_api_memory_update(body) - } - if str_eq(clean, "/api/neuron/recall") { - return handle_api_recall(method, path, body) - } - if str_eq(clean, "/api/neuron/consolidate") { - return handle_api_consolidate(body) - } - if str_eq(clean, "/api/neuron/cultivate") { - return handle_api_cultivate(body) - } - if str_starts_with(clean, "/api/connectors") { - return handle_connectors(method, clean, body) - } - return err_404(clean) - } - - if str_eq(method, "DELETE") { - // DELETE /api/sessions/:id — delete a session and its history - if str_starts_with(clean, "/api/sessions/") { - let del_after: String = str_slice(clean, 14, str_len(clean)) - let del_slash: Int = str_index_of(del_after, "/") - let del_id: String = if del_slash < 0 { del_after } else { str_slice(del_after, 0, del_slash) } - if !str_eq(del_id, "") { - return session_delete(del_id) - } - } - return err_404(clean) - } - - if str_eq(method, "PATCH") { - // PATCH /api/sessions/:id — update session title and/or folder - if str_starts_with(clean, "/api/sessions/") { - let patch_after: String = str_slice(clean, 14, str_len(clean)) - let patch_slash: Int = str_index_of(patch_after, "/") - let patch_id: String = if patch_slash < 0 { patch_after } else { str_slice(patch_after, 0, patch_slash) } - if !str_eq(patch_id, "") { - return session_update_patch(patch_id, body) - } - } - return err_404(clean) - } - return err_405(method, clean) } diff --git a/routes.elh b/routes.elh index eab06e6..664f53d 100644 --- a/routes.elh +++ b/routes.elh @@ -12,5 +12,4 @@ extern fn route_synthesize(body: String) -> String extern fn handle_dharma_recv(body: String) -> String extern fn connectd_get(suffix: String) -> String extern fn connectd_post(suffix: String, body: String) -> String -extern fn handle_connectors(method: String, clean: String, body: String) -> String extern fn handle_request(method: String, path: String, body: String) -> String