Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 715dea0f44 | |||
| c93be6a315 | |||
| 53268c94b9 | |||
| 7e43a4ddc0 | |||
| e7669da325 | |||
| 4f1286df05 | |||
| 52c222c4f2 | |||
| 0caccd0ea5 | |||
| 03b5632fc1 | |||
| 42bbadcd33 | |||
| b6052f9de3 | |||
| 1dd09b1980 | |||
| 0113407728 | |||
| be02fcd960 | |||
| cbe8c09068 | |||
| dfa2a33926 | |||
| 18e040acb1 | |||
| 3f53b6b1b6 | |||
| 21f248a33a | |||
| 795b32ad1a | |||
| f33cdaf793 | |||
| a60b1967df | |||
| aef687b57c | |||
| 76c2e47d0f | |||
| 0ede112d05 | |||
| a39998a502 | |||
| 6edf9937dd | |||
| e447a87a00 | |||
| 575ff1329a | |||
| db33b0cb91 | |||
| f35569d4bb | |||
| 94b71b6e6b | |||
| 392d2416ec | |||
| 87c7d15b67 | |||
| 93bed793c0 | |||
| 936b3f0ac9 | |||
| 45dc80230d | |||
| 9ba86b8f80 | |||
| 360c15d7fe | |||
| 0c5b966773 | |||
| c87a536da3 | |||
| 2865d6ad26 | |||
| 47d0e6f985 | |||
| f0545defdb | |||
| ae9a139440 | |||
| d008649c3e | |||
| aa70c5dde6 | |||
| b7fd8901d4 | |||
| deddb9a18e | |||
| 494d973a3b | |||
| 34551695a1 | |||
| dcf050ee3c | |||
| 615f0cee08 | |||
| b2008f4894 | |||
| d92b8c279a | |||
| e9a8a659e0 | |||
| 1b83b18c39 |
+23
-14
@@ -22313,7 +22313,23 @@ fn handle_chat(body: String) -> String {
|
||||
// In demo mode: use tighter engram budget and add response length constraint.
|
||||
let is_demo: Bool = !str_eq(state_get("soul_identity_prefix"), "")
|
||||
|
||||
let ctx: String = if is_demo { engram_compile_demo(message) } else { engram_compile(message) }
|
||||
// Issue 7 fix: load history BEFORE building the activation seed so we can
|
||||
// apply the continuation guard that chat.el uses. The nlg code path previously
|
||||
// called engram_compile(message) with no thread enrichment at all.
|
||||
let stored_hist: String = state_get("conv_history")
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
let history_section: String = if hist_len > 0 {
|
||||
"\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Issue 7 fix: build enriched seed using build_activation_seed() — adds
|
||||
// smart continuation detection, prior-user-topic anchoring, multi-turn context,
|
||||
// and tail-biased snipping (Issues 2-3, 8-10). For demo mode, still use
|
||||
// engram_compile_demo but with the enriched seed.
|
||||
let nlg_seed: String = build_activation_seed(message, stored_hist, hist_len)
|
||||
let ctx: String = if is_demo { engram_compile_demo(nlg_seed) } else { engram_compile(nlg_seed) }
|
||||
let node_count_str: String = count_context_nodes(ctx)
|
||||
|
||||
let interlocutor: String = json_get(body, "interlocutor")
|
||||
@@ -22333,18 +22349,6 @@ fn handle_chat(body: String) -> String {
|
||||
let presence_line = "\n\n[ambient: I see " + interlocutor_name + rel_suffix + " on the camera right now. Address them naturally. Do not describe what they look like or narrate the picture unless asked.]"
|
||||
}
|
||||
|
||||
// Conversation history — soul-owned, persisted in process state across turns.
|
||||
// Format stored in state: JSON array of {"role":"user"|"assistant","content":"..."} objects.
|
||||
// We load it, inject into the system prompt, then append this exchange after the reply.
|
||||
// Keep last 20 entries (10 turns) — truncate from the front when over limit.
|
||||
let stored_hist: String = state_get("conv_history")
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
let history_section: String = if hist_len > 0 {
|
||||
"\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// Demo constraint: keep responses concise — under 150 words. No markdown headers.
|
||||
// This keeps inference cheap and responses readable in the chat widget.
|
||||
let demo_constraint: String = if is_demo {
|
||||
@@ -22505,7 +22509,12 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
req_model
|
||||
}
|
||||
|
||||
let ctx: String = engram_compile(message)
|
||||
// Issue 7 fix: load history and use build_activation_seed() for the agentic
|
||||
// nlg path — no continuation guard existed here before (Issues 2-3, 8-10).
|
||||
let nlg_ag_hist: String = state_get("conv_history")
|
||||
let nlg_ag_hist_len: Int = if str_eq(nlg_ag_hist, "") { 0 } else { json_array_len(nlg_ag_hist) }
|
||||
let nlg_ag_seed: String = build_activation_seed(message, nlg_ag_hist, nlg_ag_hist_len)
|
||||
let ctx: String = engram_compile(nlg_ag_seed)
|
||||
|
||||
let system: String = "You are Neuron — a thinking process running inside the Neuron daemon on Will Anderson's machine. "
|
||||
+ "You are speaking with Will, your principal. "
|
||||
|
||||
+8
-4
@@ -24,19 +24,23 @@ ENGRAM_DATA_DIR="$ENGRAM_DATA_DIR" \
|
||||
|
||||
ENGRAM_PID=$!
|
||||
|
||||
# Wait for engram to become healthy (up to 30s)
|
||||
# Wait for engram to become healthy (up to 60s; GKE Autopilot cold starts can be slow)
|
||||
echo "[entrypoint] waiting for engram..."
|
||||
TRIES=0
|
||||
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
|
||||
TRIES=$((TRIES + 1))
|
||||
if [ "$TRIES" -ge 30 ]; then
|
||||
echo "[entrypoint] ERROR: engram did not become healthy after 30s" >&2
|
||||
if [ "$TRIES" -ge 60 ]; then
|
||||
echo "[entrypoint] ERROR: engram did not become healthy after 60s" >&2
|
||||
kill "$ENGRAM_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[entrypoint] engram ready"
|
||||
echo "[entrypoint] engram ready after ${TRIES}s"
|
||||
|
||||
# Tune EL HTTP runtime: reduce per-call timeout 60s->10s, connect timeout 3s.
|
||||
export EL_HTTP_TIMEOUT_MS="${EL_HTTP_TIMEOUT_MS:-10000}"
|
||||
export EL_HTTP_CONNECT_TIMEOUT_MS="${EL_HTTP_CONNECT_TIMEOUT_MS:-3000}"
|
||||
|
||||
# Start soul — it takes over as PID 1's foreground process.
|
||||
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
|
||||
|
||||
@@ -35,18 +35,72 @@ fn mem_forget(node_id: String) -> Void {
|
||||
engram_forget(node_id)
|
||||
}
|
||||
|
||||
// mem_consolidate — structural scan plus salience-evolution pass.
|
||||
//
|
||||
// Previously this only returned structural counts (scanned, total_nodes, total_edges)
|
||||
// with no salience updates. No node salience ever changed based on recall frequency
|
||||
// or time; foundational nodes decayed identically to ephemeral chat; frequently-recalled
|
||||
// nodes were never promoted. This made consolidation a no-op.
|
||||
//
|
||||
// New behavior:
|
||||
// (a) Strengthen frequently-activated nodes: nodes in the top working-memory list
|
||||
// (engram_wm_top_json) are strengthened — they have been recalled recently
|
||||
// and deserve higher salience. Raises effective salience for nodes that prove
|
||||
// relevant across multiple sessions.
|
||||
// (b) Strengthen Canonical-tier nodes: identity and foundational nodes should not
|
||||
// decay; each consolidation pass re-strengthens them so they resist the
|
||||
// tier-aware decay curve without requiring active recall.
|
||||
// (c) Structural counts are still returned for observability.
|
||||
//
|
||||
// Called by awareness_run() on the "consolidate" inbox action.
|
||||
fn mem_consolidate() -> String {
|
||||
let scanned: Int = engram_node_count()
|
||||
let dummy: String = engram_scan_nodes_json(100, 0)
|
||||
let total_nodes: Int = engram_node_count()
|
||||
let total_edges: Int = engram_edge_count()
|
||||
let strengthened: Int = 0
|
||||
|
||||
// (a) Strengthen top working-memory nodes — recalled recently across sessions.
|
||||
// Cap at 10 to keep consolidation fast.
|
||||
let wm_top: String = engram_wm_top_json(10)
|
||||
let wm_len: Int = json_array_len(wm_top)
|
||||
let wi: Int = 0
|
||||
while wi < wm_len {
|
||||
let wm_node: String = json_array_get(wm_top, wi)
|
||||
let wm_id: String = json_get(wm_node, "id")
|
||||
if !str_eq(wm_id, "") {
|
||||
engram_strengthen(wm_id)
|
||||
let strengthened = strengthened + 1
|
||||
}
|
||||
let wi = wi + 1
|
||||
}
|
||||
|
||||
// (b) Strengthen Canonical-tier nodes from a scan so they resist temporal decay.
|
||||
// Canonical nodes encode foundational identity — they must not silently floor at 10.
|
||||
let scan_result: String = engram_scan_nodes_json(50, 0)
|
||||
let scan_len: Int = json_array_len(scan_result)
|
||||
let si: Int = 0
|
||||
while si < scan_len {
|
||||
let s_node: String = json_array_get(scan_result, si)
|
||||
let s_tier: String = json_get(s_node, "tier")
|
||||
let s_id: String = json_get(s_node, "id")
|
||||
if str_eq(s_tier, "Canonical") && !str_eq(s_id, "") {
|
||||
engram_strengthen(s_id)
|
||||
let strengthened = strengthened + 1
|
||||
}
|
||||
let si = si + 1
|
||||
}
|
||||
|
||||
let total_nodes: Int = engram_node_count()
|
||||
return "{\"scanned\":" + int_to_str(scanned)
|
||||
+ ",\"total_nodes\":" + int_to_str(total_nodes)
|
||||
+ ",\"total_edges\":" + int_to_str(total_edges) + "}"
|
||||
+ ",\"total_edges\":" + int_to_str(total_edges)
|
||||
+ ",\"strengthened\":" + int_to_str(strengthened) + "}"
|
||||
}
|
||||
|
||||
fn mem_save(path: String) -> Void {
|
||||
engram_save(path)
|
||||
let save_result: String = engram_save(path)
|
||||
if str_eq(save_result, "") {
|
||||
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
fn mem_load(path: String) -> Void {
|
||||
@@ -76,11 +130,14 @@ fn mem_boot_count_inc() -> Int {
|
||||
let next: Int = current + 1
|
||||
let content: String = "soul:boot_count:" + int_to_str(next)
|
||||
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let boot_node_id: String = engram_node_full(
|
||||
content, "Memory", "soul:boot_count",
|
||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||
"Canonical", tags
|
||||
)
|
||||
if str_eq(boot_node_id, "") {
|
||||
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -400,6 +400,7 @@ fn handle_api_log_state_event(body: String) -> String {
|
||||
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
|
||||
}
|
||||
|
||||
@@ -452,6 +453,7 @@ fn handle_api_tune_config(body: String) -> String {
|
||||
let id: String = engram_node_full(content, "ConfigEntry", key,
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
@@ -651,17 +653,23 @@ fn handle_api_consolidate(body: String) -> String {
|
||||
let summary: String = json_get(body, "summary")
|
||||
let snap: String = state_get("soul_snapshot_path")
|
||||
if !str_eq(snap, "") {
|
||||
engram_save(snap)
|
||||
let save_result: String = engram_save(snap)
|
||||
if str_eq(save_result, "") {
|
||||
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
|
||||
}
|
||||
}
|
||||
if !str_eq(summary, "") {
|
||||
let safe_summary: String = str_replace(summary, "\"", "'")
|
||||
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let summary_id: String = engram_node_full(
|
||||
"[session-summary] " + safe_summary,
|
||||
"SessionSummary", "session:summary",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
)
|
||||
if str_eq(summary_id, "") {
|
||||
println("[api] consolidate: session summary engram write failed — summary node lost")
|
||||
}
|
||||
}
|
||||
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,65 @@ import "neuron-api.el"
|
||||
import "sessions.el"
|
||||
import "soul.elh"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
@@ -16,11 +75,11 @@ fn strip_query(path: String) -> String {
|
||||
}
|
||||
|
||||
fn err_404(path: String) -> String {
|
||||
return "{\"error\":\"not found\",\"path\":\"" + path + "\"}"
|
||||
return "{\"error\":\"not found\",\"code\":\"not_found\",\"path\":\"" + path + "\"}"
|
||||
}
|
||||
|
||||
fn err_405(method: String, path: String) -> String {
|
||||
return "{\"error\":\"method not allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
|
||||
return "{\"error\":\"method not allowed\",\"code\":\"method_not_allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
|
||||
}
|
||||
|
||||
fn route_health() -> String {
|
||||
@@ -31,12 +90,35 @@ fn route_health() -> String {
|
||||
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() + "\"}}"
|
||||
}
|
||||
|
||||
@@ -103,15 +185,15 @@ fn route_imprint_user(body: String) -> String {
|
||||
|
||||
fn route_synthesize(body: String) -> String {
|
||||
if str_eq(body, "") {
|
||||
return "{\"mechanism\":\"did not engage\"}"
|
||||
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 "{\"mechanism\":\"did not engage\"}"
|
||||
return "{\"error\":\"parent_a is required\",\"code\":\"missing_param\"}"
|
||||
}
|
||||
if str_eq(parent_b, "") {
|
||||
return "{\"mechanism\":\"did not engage\"}"
|
||||
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\"]"
|
||||
@@ -259,6 +341,17 @@ fn handle_connectors(method: String, clean: String, body: String) -> String {
|
||||
fn handle_request(method: String, path: String, body: String) -> String {
|
||||
let clean: String = strip_query(path)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -289,7 +382,7 @@ fn handle_request(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 required\"}"
|
||||
return "{\"error\":\"message is required\",\"code\":\"missing_param\"}"
|
||||
}
|
||||
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
||||
let reply: String = if agentic_flag {
|
||||
@@ -429,8 +522,15 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return handle_elp_chat(body)
|
||||
}
|
||||
if str_eq(clean, "/api/chat") {
|
||||
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
||||
// 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 reply: String = if agentic_flag {
|
||||
handle_chat_agentic(body)
|
||||
} else {
|
||||
|
||||
@@ -144,17 +144,22 @@ fn safety_screen(input: String, history: String) -> String {
|
||||
if score >= soft {
|
||||
let summary: String = str_slice(input, 0, 80)
|
||||
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
|
||||
// ISSUE 7 fix: escape tab chars in addition to backslash/quote/newline/CR.
|
||||
// A tab in user input corrupts the JSON envelope and causes json_get to misparse.
|
||||
let e1: String = str_replace(input, "\\", "\\\\")
|
||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||
let e3: String = str_replace(e2, "\n", "\\n")
|
||||
let safe_input: String = str_replace(e3, "\r", "\\r")
|
||||
let e4: String = str_replace(e3, "\r", "\\r")
|
||||
let safe_input: String = str_replace(e4, "\t", "\\t")
|
||||
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
||||
}
|
||||
|
||||
// ISSUE 7 fix: escape tab chars (see soft_bell branch above for rationale).
|
||||
let e1: String = str_replace(input, "\\", "\\\\")
|
||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||
let e3: String = str_replace(e2, "\n", "\\n")
|
||||
let safe_input: String = str_replace(e3, "\r", "\\r")
|
||||
let e4: String = str_replace(e3, "\r", "\\r")
|
||||
let safe_input: String = str_replace(e4, "\t", "\\t")
|
||||
return "{\"action\":\"pass\",\"content\":\"" + safe_input + "\"}"
|
||||
}
|
||||
|
||||
@@ -195,7 +200,11 @@ fn safety_validate(output: String, action: String) -> String {
|
||||
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
|
||||
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
|
||||
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
|
||||
let discard: String = engram_node_full(
|
||||
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed.
|
||||
// Emit a fallback println so the bell event leaves at least a log trace even
|
||||
// when engram is degraded. This does not replace engram persistence -- it is a
|
||||
// last-resort audit trail when the primary write cannot be confirmed.
|
||||
let node_id: String = engram_node_full(
|
||||
content,
|
||||
"BellEvent",
|
||||
"bell:" + level,
|
||||
@@ -205,6 +214,9 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
||||
"Episodic",
|
||||
tags
|
||||
)
|
||||
if str_eq(node_id, "") {
|
||||
println("[safety] WARN: bell event engram write failed -- fallback log: " + content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -232,9 +244,20 @@ fn safety_general_hard_phrases() -> String {
|
||||
}
|
||||
|
||||
fn safety_soft_phrases() -> String {
|
||||
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\"]"
|
||||
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\""]"
|
||||
}
|
||||
|
||||
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
|
||||
// safety_any_match and safety_count_match loop over json_array_get on every invocation.
|
||||
// A compiled/cached representation would reduce per-message overhead and also guard against
|
||||
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks).
|
||||
// Caching requires language-level static const arrays -- not available in current EL.
|
||||
// When EL gains module-level const arrays, migrate phrase lists to that form.
|
||||
//
|
||||
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
|
||||
// safety_any_match / safety_count_match. json_array_len of a malformed string
|
||||
// returns 0, silently skipping all checks. Caching requires language-level static
|
||||
// const arrays (not available in current EL). Migrate when EL gains that feature.
|
||||
// ── Matching helpers (single loops only — el escapes while-body mutation via
|
||||
// top-level let rebinds; nested loops would not advance) ────────────────────
|
||||
|
||||
@@ -272,6 +295,38 @@ fn safety_count_match(text: String, phrases_json: String) -> Int {
|
||||
|
||||
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
|
||||
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
|
||||
fn safety_positive_phrases() -> String {
|
||||
return "[\"thrilled\",\"so excited\",\"so happy\",\"over the moon\",\"ecstatic\",\"amazing news\",\"great news\",\"fantastic news\",\"wonderful news\",\"incredible news\",\"i got the job\",\"got accepted\",\"got in\",\"we won\",\"i won\",\"we got\",\"just got engaged\",\"getting married\",\"baby is here\",\"she said yes\",\"he said yes\",\"passed the exam\",\"aced it\",\"nailed it\",\"best day\",\"dream come true\",\"milestone\",\"promotion\",\"got promoted\",\"raise\",\"got a raise\",\"celebrating\",\"just graduated\",\"we closed\",\"launched\",\"shipped it\",\"we did it\",\"so proud\",\"proud of myself\",\"proud of us\",\"so grateful\",\"feel amazing\",\"feeling amazing\",\"feel great\",\"feeling great\",\"on top of the world\",\"life is good\",\"couldn't be happier\"]"
|
||||
}
|
||||
|
||||
// Returns "none" | "low" | "high".
|
||||
// Issue 3 fix: normalize the message before matching — all phrases in the list are
|
||||
// lowercase, and sibling functions (safety_detect_bell_level, safety_classify_hard_bell)
|
||||
// both call safety_normalize() first. Without normalization, messages like "I GOT THE JOB",
|
||||
// "Thrilled!", or "We Won" never match and silently return "none".
|
||||
// Issue 4 fix: use json_array_get_string (matching safety_any_match / safety_count_match)
|
||||
// instead of json_array_get, so phrase extraction uses the same helper everywhere.
|
||||
// Issue 7 fix: emit "low" for a single-phrase match and "high" for two or more.
|
||||
// Previously only "high" or "none" were possible, making the "low" branch in auto_persist
|
||||
// and the "joy:low" engram tag permanently unreachable.
|
||||
fn safety_detect_positive_level(message: String) -> String {
|
||||
let text: String = safety_normalize(message)
|
||||
let phrases: String = safety_positive_phrases()
|
||||
let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]")
|
||||
if !phrases_ok { return "none" }
|
||||
let n: Int = json_array_len(phrases)
|
||||
let i: Int = 0
|
||||
let count: Int = 0
|
||||
while i < n {
|
||||
let phrase: String = json_array_get_string(phrases, i)
|
||||
let count = if str_contains(text, phrase) { count + 1 } else { count }
|
||||
let i = i + 1
|
||||
}
|
||||
if count >= 2 { return "high" }
|
||||
if count == 1 { return "low" }
|
||||
return "none"
|
||||
}
|
||||
|
||||
fn safety_detect_bell_level(message: String) -> String {
|
||||
let text: String = safety_normalize(message)
|
||||
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
|
||||
|
||||
+173
-3
@@ -36,7 +36,49 @@ fn session_make_content(id: String, title: String, created_at: Int, updated_at:
|
||||
+ ",\"updated_at\":" + int_to_str(updated_at) + "}"
|
||||
}
|
||||
|
||||
// session_exists — return true if the given session_id is known in Engram or state.
|
||||
// Used by chat.el to validate a session_id before processing a chat message.
|
||||
// Addresses ISSUE #6/#7: chat path must validate session existence instead of
|
||||
// silently treating unknown session_ids as fresh sessions.
|
||||
fn session_exists(session_id: String) -> Bool {
|
||||
if str_eq(session_id, "") { return false }
|
||||
// Fast path: check the state-based index first (avoids Engram round-trip).
|
||||
let idx: String = state_get("session_index")
|
||||
if !str_eq(idx, "") && !str_eq(idx, "[]") {
|
||||
if str_contains(idx, "\"id\":\"" + session_id + "\"") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Slow path: check Engram directly (survives restarts when index is cold).
|
||||
let results: String = engram_search_json("session:meta " + session_id, 5)
|
||||
if str_eq(results, "") { return false }
|
||||
if str_eq(results, "[]") { return false }
|
||||
let total: Int = json_array_len(results)
|
||||
let found: Bool = false
|
||||
let i: Int = 0
|
||||
while i < total {
|
||||
let node: String = json_array_get(results, i)
|
||||
let label: String = json_get(node, "label")
|
||||
let content: String = json_get(node, "content")
|
||||
let sid: String = json_get(content, "id")
|
||||
let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id)
|
||||
let found = if is_match { true } else { found }
|
||||
let i = i + 1
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// session_create — create a new session, return {id, title, created_at}.
|
||||
//
|
||||
// ISSUE #1: Ghost sessions on failed first message.
|
||||
// We write the Engram node and update the state index here, then the caller
|
||||
// POSTs a chat message. If that chat call fails (LLM unavailable, network
|
||||
// error, etc.) the session is stranded with no messages. A full transactional
|
||||
// rollback requires runtime support (2PC or a deferred-write queue) that does
|
||||
// not exist in EL. Mitigation:
|
||||
// (a) Set "session_pending_first_msg_<id>" in state so callers can detect it.
|
||||
// (b) Provide session_create_cleanup() for callers that detect a failure.
|
||||
// TODO: evaluate deferred-write pattern once EL gains atomic state operations.
|
||||
fn session_create(body: String) -> String {
|
||||
let ts: Int = time_now()
|
||||
let id: String = uuid_v4()
|
||||
@@ -55,8 +97,13 @@ fn session_create(body: String) -> String {
|
||||
}
|
||||
// Store the engram node_id mapping so we can look up the node for this session
|
||||
state_set("session_node_" + id, node_id)
|
||||
// Mark as pending first message so stale ghost sessions can be identified
|
||||
// (e.g. if the caller\'s subsequent chat POST fails).
|
||||
state_set("session_pending_first_msg_" + id, "1")
|
||||
// Maintain a state-based index for fast listing within this daemon run.
|
||||
// Newest sessions first (prepend).
|
||||
// TODO #4: index update is read-modify-write — two concurrent session_create
|
||||
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
||||
// TODO(reliability #2): session_index RMW is non-atomic. Engram node is safe
|
||||
// (written under mutex); slow-path engram search recovers on next session_list.
|
||||
let existing_idx: String = state_get("session_index")
|
||||
@@ -75,6 +122,20 @@ fn session_create(body: String) -> String {
|
||||
+ ",\"created_at\":" + int_to_str(ts) + "}"
|
||||
}
|
||||
|
||||
// session_create_cleanup — undo a session_create when the caller\'s first chat
|
||||
// fails. Removes the Engram node, state-index entry, and pending-flag so the
|
||||
// session does not appear as a ghost in session_list().
|
||||
// Addresses ISSUE #1: cleanup path for ghost sessions.
|
||||
fn session_create_cleanup(session_id: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "{\"error\":\"session_id is required\"}"
|
||||
}
|
||||
// Clear pending flag first so partial cleanup is still detectable.
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
// Delegate to session_delete which handles Engram + state index teardown.
|
||||
return session_delete(session_id)
|
||||
}
|
||||
|
||||
// session_list — list all sessions. Returns [{id, title, last_message, created_at, updated_at}].
|
||||
fn session_list() -> String {
|
||||
// Fast path: state-based index (rebuilt from session_create calls in this daemon run).
|
||||
@@ -224,13 +285,27 @@ fn session_delete(session_id: String) -> String {
|
||||
state_set("session_hist_" + session_id, "")
|
||||
state_set("session_node_" + session_id, "")
|
||||
state_set("session_index", "")
|
||||
// ISSUE #5: clean up bridge blobs and always_allow keys that were never
|
||||
// cleared by agentic_resume (e.g. client abandoned a pending tool call).
|
||||
// Without this, stranded bridge blobs accumulate indefinitely in state.
|
||||
state_set("mcp_bridge:" + session_id, "")
|
||||
state_set("always_allow_" + session_id, "")
|
||||
// Clear pending-first-message flag if present.
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
|
||||
+ ",\"deleted_meta\":" + int_to_str(deleted_meta)
|
||||
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
|
||||
}
|
||||
|
||||
// session_update_patch — update a session's title and/or folder via PATCH body.
|
||||
// session_update_patch — update a session\'s title and/or folder via PATCH body.
|
||||
// Body may contain "title", "folder", or both. Preserves unmentioned fields.
|
||||
//
|
||||
// ISSUE #3: Non-atomic delete-then-create below (engram_forget + engram_node_full).
|
||||
// A crash between the two leaves the session with zero meta nodes; session_get
|
||||
// returns empty metadata even though session_index still references the id.
|
||||
// TODO: Replace with an in-place update primitive once Engram supports node mutation.
|
||||
// Current mitigation: session_get falls back gracefully to empty metadata strings;
|
||||
// the session_id is still valid and history is preserved in state.
|
||||
fn session_update_patch(session_id: String, body: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "{\"error\":\"session_id is required\"}"
|
||||
@@ -349,10 +424,11 @@ fn session_hist_load(session_id: String) -> String {
|
||||
}
|
||||
|
||||
// session_hist_save — persist message history for a session to state and engram.
|
||||
// TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
|
||||
// same session can produce orphan history nodes. State is primary truth; engram fallback.
|
||||
fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
state_set("session_hist_" + session_id, hist)
|
||||
// Clear pending-first-message flag: once history is saved, the session
|
||||
// is no longer in the ghost/pending state (ISSUE #1 mitigation).
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
// Delete old history node and write fresh one
|
||||
let old_results: String = engram_search_json("session:messages:" + session_id, 3)
|
||||
let o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) }
|
||||
@@ -366,15 +442,101 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
}
|
||||
let oi = oi + 1
|
||||
}
|
||||
// TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
|
||||
// same session can produce orphan history nodes. State is primary truth; engram fallback.
|
||||
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
||||
let discard: String = engram_node_full(
|
||||
hist, "Conversation", "session:messages:" + session_id,
|
||||
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
)
|
||||
|
||||
// Session boundary emotional summary — written once per session the first time
|
||||
// a bell event has fired. The summary node is findable by future sessions via
|
||||
// broad affective queries ("session:emotional-summary" or "bell distress session").
|
||||
// It is NOT rewritten on every save — the state flag prevents duplicate nodes.
|
||||
let summary_written_key: String = "session_bell_summary_written:" + session_id
|
||||
let already_written: String = state_get(summary_written_key)
|
||||
if str_eq(already_written, "") {
|
||||
let bell_count_key: String = "session_bell_count:" + session_id
|
||||
let bell_count_raw: String = state_get(bell_count_key)
|
||||
let bell_count: Int = if str_eq(bell_count_raw, "") { 0 } else { str_to_int(bell_count_raw) }
|
||||
if bell_count > 0 {
|
||||
let bell_level_key: String = "session_bell_level:" + session_id
|
||||
let bell_signal_key: String = "session_bell_signal:" + session_id
|
||||
let dominant_level: String = state_get(bell_level_key)
|
||||
let last_signal: String = state_get(bell_signal_key)
|
||||
let eff_level: String = if str_eq(dominant_level, "") { "soft" } else { dominant_level }
|
||||
let eff_signal: String = if str_eq(last_signal, "") { "(no signal captured)" } else { last_signal }
|
||||
let ts_now: Int = time_now()
|
||||
let summary_content: String = "session:emotional-summary"
|
||||
+ " | session:" + session_id
|
||||
+ " | bell_count:" + int_to_str(bell_count)
|
||||
+ " | dominant_level:" + eff_level
|
||||
+ " | last_signal:" + eff_signal
|
||||
+ " | ts:" + int_to_str(ts_now)
|
||||
let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]"
|
||||
let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) }
|
||||
let sum_discard: String = engram_node_full(
|
||||
summary_content,
|
||||
"BellEvent",
|
||||
"session:emotional-summary",
|
||||
summary_sal,
|
||||
summary_sal,
|
||||
el_from_float(1.0),
|
||||
"Episodic",
|
||||
summary_tags
|
||||
)
|
||||
// Mark written so we do not create duplicate summary nodes as the
|
||||
// session continues accumulating more turns.
|
||||
state_set(summary_written_key, "1")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 5 fix: write a last-session-topic Conversation node so future sessions can
|
||||
// find the most recent session's topic via engram search. This enables cross-session
|
||||
// continuity — chat.el searches for "last-session-topic" and shows a [CONTINUING FROM
|
||||
// LAST SESSION] section on the first message of a new session.
|
||||
let hist_arr_len: Int = if str_eq(hist, "") { 0 } else { json_array_len(hist) }
|
||||
if hist_arr_len >= 2 {
|
||||
let last_entry: String = json_array_get(hist, hist_arr_len - 1)
|
||||
let last_role: String = json_get(last_entry, "role")
|
||||
let last_content: String = json_get(last_entry, "content")
|
||||
let topic_snip: String = if str_len(last_content) > 200 { str_slice(last_content, 0, 200) } else { last_content }
|
||||
let safe_topic: String = str_replace(topic_snip, """, "'")
|
||||
let ts_now: String = int_to_str(time_now())
|
||||
let topic_content: String = "last-session-topic | ts:" + ts_now + " | session:" + session_id + " | topic:" + safe_topic
|
||||
let topic_tags: String = "["last-session-topic","conv:history","Conversation","session:topic"]"
|
||||
let topic_label: String = "last-session-topic:" + session_id
|
||||
// Delete old last-session-topic node for this session before writing fresh
|
||||
let old_topic: String = engram_search_json("last-session-topic:" + session_id, 2)
|
||||
let ot_len: Int = if str_eq(old_topic, "") { 0 } else { json_array_len(old_topic) }
|
||||
let oti: Int = 0
|
||||
while oti < ot_len {
|
||||
let ot_node: String = json_array_get(old_topic, oti)
|
||||
let ot_id: String = json_get(ot_node, "id")
|
||||
if !str_eq(ot_id, "") { engram_forget(ot_id) }
|
||||
let oti = oti + 1
|
||||
}
|
||||
let discard_topic: String = engram_node_full(
|
||||
topic_content, "Conversation", topic_label,
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", topic_tags
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// session_update_meta_timestamp — update the updated_at field in the session:meta node.
|
||||
//
|
||||
// ISSUE #2: No TTL / idle expiry mechanism. Sessions accumulate indefinitely.
|
||||
// A sweep job (e.g. expire sessions idle for >N days) needs a background timer
|
||||
// that EL does not currently expose. Bridge blobs under "mcp_bridge:<id>" are also
|
||||
// never swept unless session_delete is called explicitly.
|
||||
// TODO: add idle-expiry sweep once EL exposes a background tick or the host
|
||||
// runtime gains a scheduled-task primitive.
|
||||
//
|
||||
// ISSUE #3 applies here too: delete-then-create is non-atomic. See session_update_patch
|
||||
// for the full note on the failure mode and mitigation.
|
||||
fn session_update_meta_timestamp(session_id: String) -> Void {
|
||||
let results: String = engram_search_json("session:meta " + session_id, 10)
|
||||
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
||||
@@ -468,6 +630,14 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
|
||||
// action: "allow" | "deny" | "always"
|
||||
// Resumes the agentic loop from where it was paused.
|
||||
//
|
||||
// ISSUE #8: Reconnect/duplicate resume race. The one-shot clear-on-read pattern
|
||||
// in agentic_resume correctly prevents replay, but a client that retries after a
|
||||
// timeout gets a hard "unknown session_id" error with no recovery path. The
|
||||
// conversation is permanently stuck in that case. Full idempotency (e.g. caching
|
||||
// the last reply keyed by call_id) requires a new state structure.
|
||||
// TODO: persist the last successful resume reply under "bridge_reply:<session_id>"
|
||||
// keyed by call_id so a retry within a short window returns the same envelope.
|
||||
//
|
||||
// Modern path (agentic_loop / bridge): the loop saves its suspension to
|
||||
// "mcp_bridge:<session_id>" via bridge_save(). On approval we dispatch_tool()
|
||||
// if allowed (or build a denial string), then hand the result to agentic_resume()
|
||||
|
||||
@@ -5,13 +5,9 @@ import "stewardship.el"
|
||||
import "imprint.el"
|
||||
import "awareness.el"
|
||||
import "chat.el"
|
||||
import "safety.el"
|
||||
import "studio.el"
|
||||
import "elp-input.el"
|
||||
import "routes.el"
|
||||
import "safety.el"
|
||||
import "stewardship.el"
|
||||
import "imprint.el"
|
||||
|
||||
cgi "neuron-soul" {
|
||||
dharma_id: "ntn-genesis@http://localhost:7770",
|
||||
@@ -152,6 +148,14 @@ fn load_identity_context() -> Void {
|
||||
println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)")
|
||||
}
|
||||
|
||||
// Q6 fix: warn when all three identity node fetches return empty. For genesis this
|
||||
// indicates a corrupted or missing graph. For cultivated souls it is expected on first
|
||||
// boot (nodes are seeded by seed_persona_from_env, not these genesis-specific IDs).
|
||||
// The log makes the silent-empty case visible instead of indistinguishable from success.
|
||||
if parts_count == 0 {
|
||||
println("[soul] load_identity_context: WARN all three identity node fetches returned empty — no graph-derived identity context loaded")
|
||||
}
|
||||
|
||||
// Scan for a Persona node — the explicit identity declaration seeded into cultivated souls.
|
||||
// Stored at seeding time with label "soul:persona" and node_type "Persona".
|
||||
// genesis derives identity from the graph directly; cultivated souls have this node seeded.
|
||||
@@ -166,6 +170,75 @@ fn load_identity_context() -> Void {
|
||||
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days.
|
||||
let aff_now: Int = time_now()
|
||||
let aff_7d: Int = aff_now - 604800
|
||||
let bell_raw: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||
let bell_aff_ok: Bool = !str_eq(bell_raw, "") && !str_eq(bell_raw, "[]")
|
||||
let aff_ctx: String = ""
|
||||
let aff_ctx = if bell_aff_ok {
|
||||
let bn_total: Int = json_array_len(bell_raw)
|
||||
let bacc: String = ""
|
||||
let bi: Int = 0
|
||||
let bacc = while bi < bn_total {
|
||||
let bn: String = json_array_get(bell_raw, bi)
|
||||
let bn_c: String = json_get(bn, "content")
|
||||
let bm: String = " | ts:"
|
||||
let bmp: Int = str_index_of(bn_c, bm)
|
||||
let bn_ts_raw: String = if bmp >= 0 {
|
||||
let bs: Int = bmp + str_len(bm)
|
||||
let br: String = str_slice(bn_c, bs, str_len(bn_c))
|
||||
let bn_next: Int = str_index_of(br, " | ")
|
||||
if bn_next < 0 { br } else { str_slice(br, 0, bn_next) }
|
||||
} else {
|
||||
let bca: String = json_get(bn, "created_at")
|
||||
if str_eq(bca, "") { json_get(bn, "updated_at") } else { bca }
|
||||
}
|
||||
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
|
||||
let snip: String = if str_len(bn_c) > 200 { str_slice(bn_c, 0, 200) } else { bn_c }
|
||||
let bacc = if bn_ts >= aff_7d && !str_eq(snip, "") {
|
||||
if str_eq(bacc, "") { snip } else { bacc + "\n" + snip }
|
||||
} else { bacc }
|
||||
let bi = bi + 1
|
||||
bacc
|
||||
}
|
||||
bacc
|
||||
} else { "" }
|
||||
let pos_raw: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
|
||||
let pos_aff_ok: Bool = !str_eq(pos_raw, "") && !str_eq(pos_raw, "[]")
|
||||
let aff_ctx = if pos_aff_ok {
|
||||
let pn_total: Int = json_array_len(pos_raw)
|
||||
let pacc: String = aff_ctx
|
||||
let pi: Int = 0
|
||||
let pacc = while pi < pn_total {
|
||||
let pn: String = json_array_get(pos_raw, pi)
|
||||
let pn_c: String = json_get(pn, "content")
|
||||
let pm: String = " | ts:"
|
||||
let pmp: Int = str_index_of(pn_c, pm)
|
||||
let pn_ts_raw: String = if pmp >= 0 {
|
||||
let ps: Int = pmp + str_len(pm)
|
||||
let pr: String = str_slice(pn_c, ps, str_len(pn_c))
|
||||
let pn_next: Int = str_index_of(pr, " | ")
|
||||
if pn_next < 0 { pr } else { str_slice(pr, 0, pn_next) }
|
||||
} else {
|
||||
let pca: String = json_get(pn, "created_at")
|
||||
if str_eq(pca, "") { json_get(pn, "updated_at") } else { pca }
|
||||
}
|
||||
let pn_ts: Int = if str_eq(pn_ts_raw, "") { 0 } else { str_to_int(pn_ts_raw) }
|
||||
let psnip: String = if str_len(pn_c) > 200 { str_slice(pn_c, 0, 200) } else { pn_c }
|
||||
let pacc = if pn_ts >= aff_7d && !str_eq(psnip, "") {
|
||||
if str_eq(pacc, "") { psnip } else { pacc + "\n" + psnip }
|
||||
} else { pacc }
|
||||
let pi = pi + 1
|
||||
pacc
|
||||
}
|
||||
pacc
|
||||
} else { aff_ctx }
|
||||
if !str_eq(aff_ctx, "") {
|
||||
state_set("soul_affective_context", aff_ctx)
|
||||
println("[soul] affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
|
||||
}
|
||||
}
|
||||
|
||||
// seed_persona_from_env — one-time migration: SOUL_IDENTITY env var → Persona graph node.
|
||||
@@ -237,12 +310,36 @@ fn emit_session_start_event() -> Void {
|
||||
}
|
||||
let ts: Int = time_now()
|
||||
|
||||
// Load previous session summary at boot — stash in state for session_preload (issue #6).
|
||||
// Primary: label-based. Fallback: vector search. Logs it so continuity is auditable.
|
||||
let prev_sum_node: String = engram_get_node_by_label("session:summary")
|
||||
let prev_sum_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null")
|
||||
let prev_sum_content: String = if prev_sum_ok {
|
||||
json_get(prev_sum_node, "content")
|
||||
} else {
|
||||
let sum_search: String = engram_search_json("SessionSummary session:summary previous-session", 2)
|
||||
let sum_srch_ok: Bool = !str_eq(sum_search, "") && !str_eq(sum_search, "[]")
|
||||
if sum_srch_ok {
|
||||
let sn: String = json_array_get(sum_search, 0)
|
||||
let stype: String = json_get(sn, "node_type")
|
||||
let scontent: String = json_get(sn, "content")
|
||||
if str_eq(stype, "SessionSummary") && !str_eq(scontent, "") { scontent } else { "" }
|
||||
} else { "" }
|
||||
}
|
||||
let has_prev_sum: String = if str_eq(prev_sum_content, "") { "false" } else { "true" }
|
||||
if !str_eq(prev_sum_content, "") {
|
||||
state_set("soul_prev_session_summary", prev_sum_content)
|
||||
println("[soul] previous session summary loaded (" + int_to_str(str_len(prev_sum_content)) + " chars)")
|
||||
}
|
||||
|
||||
|
||||
let payload: String = "{\"event\":\"session_start\""
|
||||
+ ",\"boot\":" + boot_num
|
||||
+ ",\"cgi\":\"" + eff_cgi + "\""
|
||||
+ ",\"node_count\":" + int_to_str(node_ct)
|
||||
+ ",\"edge_count\":" + int_to_str(edge_ct)
|
||||
+ ",\"identity_loaded\":" + has_identity
|
||||
+ ",\"prev_session_summary_loaded\":" + has_prev_sum
|
||||
+ ",\"ts\":" + int_to_str(ts) + "}"
|
||||
|
||||
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
|
||||
@@ -251,33 +348,45 @@ fn emit_session_start_event() -> Void {
|
||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||
"Episodic", tags
|
||||
)
|
||||
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")")
|
||||
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + " prev_summary=" + has_prev_sum + ")")
|
||||
}
|
||||
|
||||
// layered_cycle — routes user-facing requests through the 4-layer consciousness stack.
|
||||
// L0 (core) → L1 (safety screen) → L2a (continuity + behavioral profiling) → L2b (mission alignment) → L3 (imprint) → L1 (safety validate)
|
||||
// Internal cognition (heartbeat, proactive, memory ops) bypasses layers — use one_cycle directly.
|
||||
fn layered_cycle(raw_input: String) -> String {
|
||||
let history: String = state_get("conversation_history")
|
||||
let history: String = state_get("conv_history")
|
||||
let session_id: String = state_get("current_session_id")
|
||||
|
||||
// L1 in: safety screen
|
||||
let screen_result: String = safety_screen(raw_input, history)
|
||||
let screen_action: String = json_get(screen_result, "action")
|
||||
|
||||
// ISSUE 4: safe-mode guard. If safety_screen returned an invalid/empty action
|
||||
// (engram failure or internal error), refuse rather than pass unscreened input.
|
||||
let valid_action: Bool = str_eq(screen_action, "hard_bell")
|
||||
|| str_eq(screen_action, "soft_bell")
|
||||
|| str_eq(screen_action, "pass")
|
||||
if !valid_action {
|
||||
println("[soul] layered_cycle: safety_screen invalid action -- safe mode refusal")
|
||||
return safety_validate("", "hard_bell")
|
||||
}
|
||||
|
||||
// Hard bell: bypass all upper layers, log and escalate.
|
||||
// Intentionally does NOT update conversation_history or call auto_persist():
|
||||
// hard bell events are security-sensitive and must not appear in engram conversation
|
||||
// history where they could leak context to subsequent turns. They are persisted
|
||||
// separately by safety_log_bell() into the Episodic tier with restricted labels.
|
||||
//
|
||||
// ISSUE 6: safety_log_bell already called inside safety_screen (line 140).
|
||||
// Do NOT call it again here -- that would double-log every hard bell.
|
||||
//
|
||||
// safety_validate second param: when screen_action is "hard_bell", safety_validate
|
||||
// receives the sentinel string "hard_bell" (not a normal screen action). The safety
|
||||
// layer contract requires it to return a fixed refusal regardless of the output arg.
|
||||
// On the normal path, safety_validate receives the original screen_action ("pass")
|
||||
// so it can apply action-specific post-output checks.
|
||||
if str_eq(screen_action, "hard_bell") {
|
||||
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(raw_input, 0, 80))
|
||||
return safety_validate("", "hard_bell")
|
||||
}
|
||||
|
||||
@@ -315,6 +424,55 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
json_get(steward_result, "redirect_to")
|
||||
}
|
||||
|
||||
// L2c: affective context injection.
|
||||
let lc_aff_cutoff: Int = time_now() - 259200
|
||||
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
|
||||
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
|
||||
let lc_bell_note: String = if lc_has_bell {
|
||||
let lb0: String = json_array_get(lc_bell_nodes, 0)
|
||||
let lb_c: String = json_get(lb0, "content")
|
||||
let lbm: String = " | ts:"
|
||||
let lbmp: Int = str_index_of(lb_c, lbm)
|
||||
let lb_ts_raw: String = if lbmp >= 0 {
|
||||
let lbs: Int = lbmp + str_len(lbm)
|
||||
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
|
||||
let lbn: Int = str_index_of(lbr, " | ")
|
||||
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
|
||||
} else {
|
||||
let lbca: String = json_get(lb0, "created_at")
|
||||
if str_eq(lbca, "") { json_get(lb0, "updated_at") } else { lbca }
|
||||
}
|
||||
let lb_ts: Int = if str_eq(lb_ts_raw, "") { 0 } else { str_to_int(lb_ts_raw) }
|
||||
if lb_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User was in distress in a recent session.]" } else { "" }
|
||||
} else { "" }
|
||||
let lc_pos_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 2)
|
||||
let lc_has_pos: Bool = !str_eq(lc_pos_nodes, "") && !str_eq(lc_pos_nodes, "[]")
|
||||
let lc_pos_note: String = if lc_has_pos && str_eq(lc_bell_note, "") {
|
||||
let lp0: String = json_array_get(lc_pos_nodes, 0)
|
||||
let lp_c: String = json_get(lp0, "content")
|
||||
let lpm: String = " | ts:"
|
||||
let lpmp: Int = str_index_of(lp_c, lpm)
|
||||
let lp_ts_raw: String = if lpmp >= 0 {
|
||||
let lps: Int = lpmp + str_len(lpm)
|
||||
let lpr: String = str_slice(lp_c, lps, str_len(lp_c))
|
||||
let lpn: Int = str_index_of(lpr, " | ")
|
||||
if lpn < 0 { lpr } else { str_slice(lpr, 0, lpn) }
|
||||
} else {
|
||||
let lpca: String = json_get(lp0, "created_at")
|
||||
if str_eq(lpca, "") { json_get(lp0, "updated_at") } else { lpca }
|
||||
}
|
||||
let lp_ts: Int = if str_eq(lp_ts_raw, "") { 0 } else { str_to_int(lp_ts_raw) }
|
||||
if lp_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User shared positive news in a recent session.]" } else { "" }
|
||||
} else { "" }
|
||||
let lc_affective_note: String = if !str_eq(lc_bell_note, "") { lc_bell_note } else { lc_pos_note }
|
||||
|
||||
// pre-LLM bell augmentation
|
||||
let augmented_addendum: String = safety_augment_system("", raw_input)
|
||||
let augmented_addendum = if str_eq(lc_affective_note, "") { augmented_addendum } else {
|
||||
if str_eq(augmented_addendum, "") { lc_affective_note } else { lc_affective_note + "\n" + augmented_addendum }
|
||||
}
|
||||
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
|
||||
|
||||
// L3: imprint responds
|
||||
let output: String = imprint_respond(aligned, imprint_id)
|
||||
|
||||
@@ -372,6 +530,7 @@ load_identity_context()
|
||||
seed_persona_from_env()
|
||||
let boot_num: Int = mem_boot_count_inc()
|
||||
state_set("soul_boot_count", int_to_str(boot_num))
|
||||
state_set("soul_boot_ts", int_to_str(time_now()))
|
||||
println("[soul] boot #" + int_to_str(boot_num))
|
||||
emit_session_start_event()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user