ff421d39f6
DESIGN FIT: three of round 7's five defects share ONE root — the conversation-history
layer persists only {role, content}, discarding tool provenance, session scoping, and the
distinction between a real user turn and an internal utility call. Fixes A and B RESTORE
Will's design rather than extend it: his agentic path already scopes history per session,
the plain path never got it, and his own source carries the TODO admitting the resulting
race (chat.el, handle_chat: "process-global key; concurrent /api/chat requests without
session_id race on this read-append-write"). Fix C repairs one join Will wrote that was
correct for a year and one we added last week. E1/E2 are ours.
FIX A — tool provenance in history (kills the FALSE CONFESSION)
Root cause, EXECUTED-verified: handle_chat_agentic recorded turns via hist_append, which
emits {"role","content"} only. server_tool_use blocks, web_search_tool_result blocks and
every citation were discarded, then replayed as text. On the next turn the model saw a
data-rich answer with zero evidence a search had happened, and its own permanent rule
("never describe a search you did not perform") left one conclusion available: that it
had fabricated the data. It apologised for a search it HAD run — four independent lines
of evidence confirm the search was real. The defect is not the model's honesty. It is
that we deleted the evidence and then asked it to account for itself.
Change: agentic_loop accumulates the source URLs it already walks past (citations and
web_search_tool_result content) and returns them as "sources"; handle_chat_agentic folds
tools_used + sources into a receipt line stored WITH the assistant turn. Receipts are
unconditional — a negative receipt ("no tools ran") is the other half of the guarantee,
because "no evidence of a tool" and "evidence of no tool" were previously identical in
the transcript. conv_history_block splits the receipt off before snipping so a long
answer cannot truncate away the evidence. The user never sees it: it is appended to the
history copy, not the reply.
FIX B — one history key for both paths (kills the BLANK STARE)
Root cause, EXECUTED-verified: the agentic path keyed history on session_hist_<id>; the
plain path was hard-wired to the process-global conv_history and never read session_id.
One conversation, two buckets. Proven in the guest engram: the scoped node held exactly
two turns starting at "Try again" while the earlier exchanges sat unscoped.
Change: conv_hist_key/conv_hist_label are now the single definition, used by BOTH paths;
session_id is threaded route -> layered_cycle -> layered_generate / conv_history_record.
The 2-line fallback (plain path reads the agentic key) was REJECTED: it keeps the
process-global bucket as a live write target, which is the bleed the TODO describes.
Also found and closed while threading: layered_cycle read session_id from the state key
"current_session_id", which is read here and WRITTEN NOWHERE in the entire source. It
was unconditionally "", so TODO(reliability #4) — per-session steward continuity — was
dead code that could never fire. It fires now.
LAZY SESSION, decided explicitly: we create the session EAGERLY at the door (app half,
ui#223) rather than migrating orphaned turns. Migration would copy the CONTENTS of a
process-global bucket, possibly another conversation's, into a named session — the bleed,
performed deliberately. Eager creation makes the situation impossible instead. Migration
is deliberately not implemented and must not be added without solving provenance first.
FIX C — the two text-join seams ("to.Good", byte-verified 0x77 0x2e 0x47)
Two bare `+` joins, written a year apart, had drifted into two answers to one question:
within-response block joins (Will's, 2026-05-03, latent until server-side web_search
began interleaving non-text blocks) and across-round joins (ours, 62af564).
Change: one named rule, text_join_sep, at both sites. NOT a blanket separator — a cited
answer splits MID-SENTENCE ("The current temperature is " + "86°F" + ", with "), so a
blanket separator shatters every sourced sentence. The rule takes the one bit that
distinguishes the cases: whether a NON-TEXT block intervened. Hoisting it also makes the
fix verifiable in the shipped binary, which an inline `+` is not.
FIX E1 — utility generations stay out of the transcript
Title generation ("Write a 3-6 word title...") and insight passes ran down the same plain
door as a real message and were recorded as if the user had typed them; the same calls are
the "model":"unknown" rows in usage.jsonl. is_utility_request reads an explicit utility
flag from the app, with the __title__/__insight__ id prefixes as a fallback for older
clients. Answered normally, never recorded.
FIX E2 — OPERATOR IDENTITY is scoped to tool-capable turns
The block (env USER/HOME, closing "This is a hard rule") was prepended to EVERY system
prompt including chat mode. On a Tools:Off turn there is no filesystem in reach, so it
governed nothing and merely supplied the loudest fact in the prompt — which is why the
model opened a fresh conversation with "You're test, on your machine at /Users/test".
Hoisted to operator_identity_block() and gated on !chat_mode. Unchanged wherever a file
or command tool can actually be reached.
ALSO: agentic_loop's per-session history persist had a second hand-rolled copy of
conv_history_persist with a different label expression, different salience scores and
different tags for the same node. Since both now derive the label from conv_hist_label and
engram_node_full upserts by label, two score policies were writing one node. Collapsed to
one writer.
BUILD NOTE: dist/elp-c-decls.h is force-included by the documented link recipe and carried
the OLD C arities, so it is updated here. This is the build-support header, NOT the stale
generated dist/soul.c — no dist/*.c was read or edited; all engine changes are .el source.
chat.elh/soul.elh are committed because a first-pass build against the old signatures FAILS
(measured); the other regenerated headers are reverted as unrelated churn.
BUILT: 887,000 bytes, sha256 d632b061ad75269d6adeb52578d030eaf49e895d91289d7f946b19c08450d728
Zero el_str_concat(<int>, str_len(...)) sites (the BUG-PLAINCHAT-1 miscompile guard).
web_search_20250305 and disable_parallel_tool_use both still present — PR #108's web search
and the ADR-0005 stopgap are intact.
Refs neuron#109 (builds on it), neuron#78 (Receipt Contract — the real fix A is a stopgap for)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
783 lines
33 KiB
EmacsLisp
783 lines
33 KiB
EmacsLisp
import "memory.el"
|
|
import "awareness.el"
|
|
import "chat.el"
|
|
import "studio.el"
|
|
import "elp-input.el"
|
|
import "neuron-api.el"
|
|
import "sessions.el"
|
|
import "soul.elh"
|
|
|
|
// flag_true — tolerant flag test: accepts both boolean `true` (Kotlin UI) and
|
|
// 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.
|
|
fn flag_true(body: String, key: String) -> Bool {
|
|
return json_get_bool(body, key) || json_get_int(body, key) > 0
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// plain_chat_envelope — the JSON response contract for a non-agentic ("Tools: Off")
|
|
// chat turn. Every /api/chat dispatch that calls layered_cycle goes through here, so
|
|
// the three call sites cannot drift apart.
|
|
//
|
|
// WHY THE ENVELOPE IS BUILT HERE AND NOT INSIDE layered_cycle:
|
|
// layered_cycle returns the user-facing text AFTER safety_validate has acted on it.
|
|
// Keeping the JSON out of the cycle means the output gate always sees raw model text
|
|
// and never an escaped blob — there is nothing to unwrap and re-wrap on the crisis
|
|
// path, which is exactly the failure mode that made wiring handle_chat unsafe.
|
|
// Escaping is the last thing that happens, strictly after the gate.
|
|
//
|
|
// FIELDS: `reply` and `response` carry the same validated text. Both are required by
|
|
// live clients — the desktop app reads `reply` first (DaemonClient.parseChatResponse),
|
|
// while the CLI tools and the Telegram gateway read `response` (the gateway reads only
|
|
// `response`). Emitting one would break the other.
|
|
//
|
|
// EMPTY MEANS FAILURE, NOT AN EMPTY ANSWER: a hard bell returns the fixed crisis
|
|
// message and a soft bell is padded to non-empty by safety_validate, so the only way
|
|
// an empty string leaves the cycle is a failed model call. It is reported as an error
|
|
// rather than dressed up as a successful blank reply.
|
|
// ---------------------------------------------------------------------------
|
|
fn plain_chat_envelope(validated: String, model: String) -> String {
|
|
if str_eq(validated, "") {
|
|
return "{\"error\":\"llm unavailable\",\"reply\":\"\",\"response\":\"\",\"agentic\":false,\"tools_used\":[]}"
|
|
}
|
|
let safe: String = json_safe(validated)
|
|
return "{\"reply\":\"" + safe + "\""
|
|
+ ",\"response\":\"" + safe + "\""
|
|
+ ",\"model\":\"" + json_safe(model) + "\""
|
|
+ ",\"agentic\":false"
|
|
+ ",\"tools_used\":[]}"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rate limiting — simple in-memory per-IP sliding window counter.
|
|
//
|
|
// State keys:
|
|
// rl:<ip>:count — request count in the current window
|
|
// rl:<ip>:window — window start timestamp (unix seconds)
|
|
//
|
|
// Limit: configurable via soul state key "soul_rate_limit" (requests per
|
|
// minute). Falls back to 60 req/min if not set. The /health endpoint is
|
|
// exempt so monitoring does not consume quota.
|
|
//
|
|
// State growth: each unique source IP accumulates exactly 2 state keys
|
|
// (count + window) for the lifetime of the process. Per-IP storage is
|
|
// bounded and constant; values reset on window expiry. In aggregate, state
|
|
// grows linearly with distinct IPs — typical for a trusted-client service.
|
|
// EL has no state_delete builtin, so keys from inactive IPs persist.
|
|
// TODO: add state_delete sweep when the EL runtime exposes that primitive.
|
|
//
|
|
// Returns "" when the request is allowed, or a 429 JSON body when rejected.
|
|
// ---------------------------------------------------------------------------
|
|
fn rate_limit_check(ip: String, path: String) -> String {
|
|
// Health checks are exempt — they must never be blocked.
|
|
if str_eq(path, "/health") {
|
|
return ""
|
|
}
|
|
|
|
let limit_str: String = state_get("soul_rate_limit")
|
|
let limit: Int = if str_eq(limit_str, "") { 60 } else { str_to_int(limit_str) }
|
|
|
|
let now: Int = time_now()
|
|
let window_key: String = "rl:" + ip + ":window"
|
|
let count_key: String = "rl:" + ip + ":count"
|
|
|
|
let win_str: String = state_get(window_key)
|
|
let win_start: Int = if str_eq(win_str, "") { now } else { str_to_int(win_str) }
|
|
|
|
// New window every 60 seconds.
|
|
let elapsed: Int = now - win_start
|
|
let in_window: Bool = elapsed < 60
|
|
|
|
let prev_count_str: String = state_get(count_key)
|
|
let prev_count: Int = if str_eq(prev_count_str, "") { 0 } else { str_to_int(prev_count_str) }
|
|
|
|
// Reset window if expired.
|
|
let eff_count: Int = if in_window { prev_count } else { 0 }
|
|
let eff_win: Int = if in_window { win_start } else { now }
|
|
|
|
let new_count: Int = eff_count + 1
|
|
state_set(count_key, int_to_str(new_count))
|
|
state_set(window_key, int_to_str(eff_win))
|
|
|
|
if new_count > limit {
|
|
let retry_after: Int = 60 - (now - eff_win)
|
|
let eff_retry: Int = if retry_after < 0 { 0 } else { retry_after }
|
|
return "{\"__status__\":429,\"error\":\"rate limit exceeded\",\"code\":\"rate_limited\",\"retry_after_secs\":" + int_to_str(eff_retry) + "}"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
fn strip_query(path: String) -> String {
|
|
let q: Int = str_index_of(path, "?")
|
|
if q < 0 {
|
|
return path
|
|
}
|
|
return str_slice(path, 0, q)
|
|
}
|
|
|
|
fn err_404(path: String) -> String {
|
|
return "{\"error\":\"not found\",\"code\":\"not_found\",\"path\":\"" + path + "\"}"
|
|
}
|
|
|
|
fn err_405(method: String, path: String) -> String {
|
|
return "{\"error\":\"method not allowed\",\"code\":\"method_not_allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
|
|
}
|
|
|
|
fn route_health() -> String {
|
|
let cgi_id: String = state_get("soul_cgi_id")
|
|
let boot: String = state_get("soul_boot_count")
|
|
let boot_num: String = if str_eq(boot, "") { "0" } else { boot }
|
|
let node_ct: Int = engram_node_count()
|
|
let edge_ct: Int = engram_edge_count()
|
|
let pulse: String = state_get("soul.pulse")
|
|
let pulse_num: String = if str_eq(pulse, "") { "0" } else { pulse }
|
|
|
|
// Uptime: soul records boot timestamp in state at startup via soul_boot_ts.
|
|
// Compute elapsed seconds; fall back to -1 if not yet set.
|
|
let boot_ts_str: String = state_get("soul_boot_ts")
|
|
let uptime_secs: Int = if str_eq(boot_ts_str, "") {
|
|
-1
|
|
} else {
|
|
time_now() - str_to_int(boot_ts_str)
|
|
}
|
|
|
|
// LLM connectivity: probe with a minimal call. Any non-error reply = ok.
|
|
// Use a short, fixed prompt so this never counts against conversation history.
|
|
let model: String = state_get("soul_model")
|
|
let eff_model: String = if str_eq(model, "") { "claude-sonnet-4-5" } else { model }
|
|
let llm_probe: String = llm_call_system(eff_model, "You are a health probe. Reply with the single word: ok", "ping")
|
|
let llm_ok: Bool = !str_eq(llm_probe, "")
|
|
&& !str_starts_with(llm_probe, "{\"error\"")
|
|
&& !str_starts_with(llm_probe, "{\"type\":\"error\"")
|
|
&& !str_contains(llm_probe, "authentication_error")
|
|
let llm_status: String = if llm_ok { "ok" } else { "unreachable" }
|
|
|
|
return "{\"status\":\"alive\""
|
|
+ ",\"cgi_id\":\"" + cgi_id + "\""
|
|
+ ",\"boot\":" + boot_num
|
|
+ ",\"uptime_secs\":" + int_to_str(uptime_secs)
|
|
+ ",\"node_count\":" + int_to_str(node_ct)
|
|
+ ",\"edge_count\":" + int_to_str(edge_ct)
|
|
+ ",\"pulse\":" + pulse_num
|
|
+ ",\"llm\":\"" + llm_status + "\""
|
|
+ ",\"layers\":{\"l0\":\"core\",\"l1\":\"safety\",\"l2\":\"stewardship\",\"l3\":\"" + imprint_current() + "\"}}"
|
|
}
|
|
|
|
fn route_lineage() -> String {
|
|
let cgi_id: String = state_get("soul_cgi_id")
|
|
let q: String = "lineage:" + cgi_id
|
|
let results: String = engram_search_json(q, 1)
|
|
let len: Int = json_array_len(results)
|
|
if len <= 0 {
|
|
return "{\"id\":\"" + cgi_id + "\""
|
|
+ ",\"tier\":\"citizen\""
|
|
+ ",\"is_founding\":true"
|
|
+ ",\"validation_attempts\":0"
|
|
+ ",\"training_sessions\":0"
|
|
+ ",\"is_sterile\":false}"
|
|
}
|
|
let raw: String = json_get_raw(results, "0")
|
|
return raw
|
|
}
|
|
|
|
fn route_imprint_contextual(body: String) -> String {
|
|
if str_eq(body, "") {
|
|
return "{\"ok\":false,\"error\":\"empty body\"}"
|
|
}
|
|
let tags: String = "[\"imprint\",\"contextual\"]"
|
|
let id: String = engram_node_full(
|
|
body,
|
|
"Entity",
|
|
"imprint:contextual",
|
|
el_from_float(0.7),
|
|
el_from_float(0.6),
|
|
el_from_float(0.9),
|
|
"Working",
|
|
tags
|
|
)
|
|
if str_eq(id, "") {
|
|
return "{\"ok\":false,\"error\":\"engram write failed\"}"
|
|
}
|
|
state_set("active_contextual_imprint", id)
|
|
return "{\"ok\":true,\"id\":\"" + id + "\"}"
|
|
}
|
|
|
|
fn route_imprint_user(body: String) -> String {
|
|
if str_eq(body, "") {
|
|
return "{\"ok\":false,\"error\":\"empty body\"}"
|
|
}
|
|
let tags: String = "[\"imprint\",\"user\"]"
|
|
let id: String = engram_node_full(
|
|
body,
|
|
"Entity",
|
|
"imprint:user",
|
|
el_from_float(0.7),
|
|
el_from_float(0.6),
|
|
el_from_float(0.9),
|
|
"Working",
|
|
tags
|
|
)
|
|
if str_eq(id, "") {
|
|
return "{\"ok\":false,\"error\":\"engram write failed\"}"
|
|
}
|
|
state_set("active_user_imprint", id)
|
|
return "{\"ok\":true,\"id\":\"" + id + "\"}"
|
|
}
|
|
|
|
fn route_synthesize(body: String) -> String {
|
|
if str_eq(body, "") {
|
|
return "{\"error\":\"body is required\",\"code\":\"missing_param\"}"
|
|
}
|
|
let parent_a: String = json_get(body, "parent_a")
|
|
let parent_b: String = json_get(body, "parent_b")
|
|
if str_eq(parent_a, "") {
|
|
return "{\"error\":\"parent_a is required\",\"code\":\"missing_param\"}"
|
|
}
|
|
if str_eq(parent_b, "") {
|
|
return "{\"error\":\"parent_b is required\",\"code\":\"missing_param\"}"
|
|
}
|
|
let req: String = "synthesize " + parent_a + " " + parent_b
|
|
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
|
|
engram_node_full(
|
|
req,
|
|
"Entity",
|
|
"synthesis-request",
|
|
el_from_float(0.8),
|
|
el_from_float(0.8),
|
|
el_from_float(0.9),
|
|
"Working",
|
|
tags
|
|
)
|
|
return "{\"mechanism\":\"did not engage\"}"
|
|
}
|
|
|
|
fn handle_dharma_recv(body: String) -> String {
|
|
let content_raw: String = json_get(body, "content")
|
|
let from_id: String = json_get(body, "from")
|
|
|
|
let event_type: String = json_get(content_raw, "event_type")
|
|
let payload: String = json_get(content_raw, "payload")
|
|
|
|
let eff_event: String = if str_eq(event_type, "") { "chat" } else { event_type }
|
|
let eff_payload: String = if str_eq(payload, "") { content_raw } else { payload }
|
|
|
|
if str_eq(eff_event, "chat") {
|
|
let msg: String = json_get(eff_payload, "message")
|
|
let chat_body: String = if str_eq(msg, "") {
|
|
"{\"message\":\"" + str_replace(str_replace(eff_payload, "\\", "\\\\"), "\"", "\\\"") + "\"}"
|
|
} else {
|
|
eff_payload
|
|
}
|
|
let agentic_flag: Bool = json_get_bool(eff_payload, "agentic")
|
|
let raw_msg: String = json_get(chat_body, "message")
|
|
let req_mode: String = json_get(chat_body, "mode")
|
|
let reply: String = if str_eq(req_mode, "plan") {
|
|
handle_chat_plan(chat_body)
|
|
} else if agentic_flag {
|
|
handle_chat_agentic(chat_body)
|
|
} else {
|
|
// Non-agentic ("Tools: Off"): the full L1→L2→L3→L1 cycle, which now generates
|
|
// at L3 instead of echoing. Envelope built outside the cycle — see
|
|
// plain_chat_envelope.
|
|
// FIX B/E1 (2026-08-05): the cycle is told which conversation it is in, and
|
|
// whether this generation is conversation at all. Same two arguments at all
|
|
// three dispatch sites.
|
|
let screened_reply: String = layered_cycle(raw_msg, json_get(chat_body, "session_id"), is_utility_request(chat_body, json_get(chat_body, "session_id")))
|
|
plain_chat_envelope(screened_reply, chat_default_model())
|
|
}
|
|
auto_persist(chat_body, reply)
|
|
return reply
|
|
}
|
|
|
|
if str_eq(eff_event, "memory") {
|
|
let query: String = json_get(eff_payload, "query")
|
|
let limit_str: String = json_get(eff_payload, "limit")
|
|
let limit: Int = if str_eq(limit_str, "") { 20 } else { str_to_int(limit_str) }
|
|
let q: String = if str_eq(query, "") { eff_payload } else { query }
|
|
return engram_search_json(q, limit)
|
|
}
|
|
|
|
if str_eq(eff_event, "tool") {
|
|
let path_field: String = json_get(eff_payload, "path")
|
|
let method_field: String = json_get(eff_payload, "method")
|
|
let tool_body: String = json_get(eff_payload, "body")
|
|
let eff_method: String = if str_eq(method_field, "") { "POST" } else { method_field }
|
|
return handle_tool(path_field, eff_method, tool_body)
|
|
}
|
|
|
|
if str_eq(eff_event, "see") {
|
|
return handle_see(eff_payload)
|
|
}
|
|
|
|
if str_eq(eff_event, "health") {
|
|
return route_health()
|
|
}
|
|
|
|
if str_eq(eff_event, "dharma_room_turn_agentic") {
|
|
return handle_dharma_room_turn_agentic(eff_payload)
|
|
}
|
|
|
|
if str_eq(eff_event, "dharma_room_turn") {
|
|
return handle_dharma_room_turn(eff_payload)
|
|
}
|
|
|
|
if str_eq(eff_event, "chat_as_soul") {
|
|
return handle_chat_as_soul(eff_payload)
|
|
}
|
|
|
|
// ELP — Engram Language Protocol: two-layer activation, no LLM
|
|
if str_eq(eff_event, "elp") {
|
|
return handle_elp_chat(eff_payload)
|
|
}
|
|
|
|
return "{\"error\":\"unknown event_type\",\"event_type\":\"" + eff_event + "\"}"
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP Connectors proxy — thin pass-through to neuron-connectd on :7771.
|
|
// The UI talks to ONE origin (the soul); all MCP/config complexity lives in
|
|
// the bridge. Bridge-down returns a clear error (not a panic).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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, "") {
|
|
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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.
|
|
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
|
|
// or if two soul instances run on the same machine (latent correctness hazard).
|
|
let tmp: String = "/tmp/neuron-connectors-req-" + int_to_str(time_now()) + ".json"
|
|
fs_write(tmp, eff)
|
|
let out: String = exec_capture("curl -s --max-time 20 -X POST http://127.0.0.1:7771" + suffix + " -H 'Content-Type: application/json' -d @" + tmp)
|
|
if str_eq(out, "") {
|
|
return "{\"ok\":false,\"error\":\"connector bridge unreachable (neuron-connectd on :7771)\"}"
|
|
}
|
|
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")
|
|
}
|
|
if str_eq(clean, "/api/connectors/add") {
|
|
return connectd_post("/mcp/servers/add", body)
|
|
}
|
|
if str_eq(clean, "/api/connectors/toggle") {
|
|
return connectd_post("/mcp/servers/toggle", body)
|
|
}
|
|
if str_eq(clean, "/api/connectors/auto-approve") {
|
|
return connectd_post("/mcp/servers/auto-approve", body)
|
|
}
|
|
if str_eq(clean, "/api/connectors/remove") {
|
|
return connectd_post("/mcp/servers/remove", body)
|
|
}
|
|
if str_eq(clean, "/api/connectors/secret") {
|
|
return connectd_post("/mcp/servers/secret", body)
|
|
}
|
|
if str_eq(clean, "/api/connectors/oauth/start") {
|
|
return connectd_post("/mcp/oauth/start", body)
|
|
}
|
|
// Call a connector tool directly (pre-chat), e.g. WhatsApp get_pairing_qr / get_login_status for
|
|
// the pairing UI. Body: {"name":"mcp__<server>__<tool>","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 "{\"ok\":false,\"error\":\"unknown connectors route\"}"
|
|
}
|
|
|
|
fn handle_request(method: String, path: String, body: String) -> String {
|
|
let clean: String = strip_query(path)
|
|
|
|
// ACTIVITY STAMP (2026-07-30 self-review): every inbound HTTP request —
|
|
// MCP wrapper calls, chat, API — marks real external activity. Before
|
|
// this, "idle" was only reset by rare inbox synthesis-requests, so the
|
|
// heartbeat idle field tracked uptime exactly (idle == pulse on every
|
|
// beat) and carried zero information. The awareness heartbeat now
|
|
// reports idle_ms = wall-clock ms since this stamp.
|
|
state_set("soul.last_activity_ts", int_to_str(time_now()))
|
|
|
|
// Rate limit check. Extract caller IP from REMOTE_ADDR env var (set by the
|
|
// EL HTTP runtime for each request). Skip enforcement when empty so
|
|
// loopback/internal callers are never blocked.
|
|
let ip: String = env("REMOTE_ADDR")
|
|
if !str_eq(ip, "") {
|
|
let rl_result: String = rate_limit_check(ip, clean)
|
|
if !str_eq(rl_result, "") {
|
|
return rl_result
|
|
}
|
|
}
|
|
|
|
if str_eq(method, "POST") && str_eq(clean, "/dharma/recv") {
|
|
return handle_dharma_recv(body)
|
|
}
|
|
|
|
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") {
|
|
// 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 }
|
|
}
|
|
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)
|
|
}
|
|
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/<type>
|
|
// 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)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|