Compare commits

...

6 Commits

Author SHA1 Message Date
will.anderson 95cb49a8b0 fix(cross-session-continuity): resolve 11 issues from code review
1. Missing closing brace on hard_bell block in handle_chat_agentic — safety gate was broken and all subsequent code unreachable.
2. Replace phantom engram_get_node_by_label() (not a runtime builtin) with engram_search_json + exact label filter in all three call sites (chat.el session_preload, session_summary_write, soul.el boot loader).
3. Fix session_summary_autogenerate scoping bug — snippets/count mutations were inside an if block and silently discarded each iteration; moved to top-level of while body using if-expressions per the el mutation rule.
4. Fix agentic session history restore — state_get fallback now calls session_hist_load (session:messages:SESSION_ID) on cold start; persist now uses session_hist_save so the write and read use the same label scheme.
5. Wire soul_prev_session_summary state key into session_preload as primary source, eliminating the dead state write.
6. Wire soul_affective_context state key into handle_chat affective prefix check, eliminating the dead state write.
7. Add session_summary_autogenerate + session_summary_write to the agentic path so users on handle_chat_agentic get session summary continuity.
8. Add import "chat.el" to neuron-api.el to make session_summary_write dependency explicit.
9. Replace corrupted em-dash bytes (\xc3\xa2\xc2\x80\xc2\x94) in session_preload headers with plain hyphen per VOICE RULE.
10. Add newline before return in handle_api_consolidate to fix statement-separator issue.
11. Add delete-before-write to conv_history_persist to prevent unbounded engram accumulation per turn.
2026-06-22 13:39:46 -05:00
will.anderson 795b32ad1a feat(recall): cross-session-continuity improvements
Neuron Soul CI / build (pull_request) Failing after 14m49s
2026-06-22 13:00:17 -05:00
will.anderson 87c7d15b67 Merge pull request 'fix(reliability): session-boundary' (#41) from improve/reliability-session-boundary into main
Neuron Soul CI / build (push) Has been cancelled
Deploy Soul to GKE / deploy (push) Has been cancelled
fix(reliability): session-boundary — lifecycle guards, cleanup on expiry
2026-06-22 17:20:33 +00:00
will.anderson 93bed793c0 Merge pull request 'fix(reliability): safety-resilience' (#39) from improve/reliability-safety-resilience into main
Neuron Soul CI / build (push) Has been cancelled
Deploy Soul to GKE / deploy (push) Has been cancelled
fix(reliability): safety-resilience — crisis detection degradation paths
2026-06-22 17:19:29 +00:00
will.anderson f0545defdb fix(reliability): session-boundary — ghost sessions, bridge leak, session validation
Neuron Soul CI / build (pull_request) Has been cancelled
- sessions.el: add session_exists() for chat-path session validation (ISSUE #6/#7)
- sessions.el: add session_create_cleanup() for ghost-session rollback (ISSUE #1)
- sessions.el: set session_pending_first_msg flag in session_create; clear it in
  session_hist_save so the first successful chat marks the session active (ISSUE #1)
- sessions.el: session_delete now clears mcp_bridge:<id> and always_allow_<id>
  state keys so abandoned pending-tool sessions do not accumulate (ISSUE #5)
- sessions.el: add TODO comments for ISSUE #2 (no TTL/expiry), ISSUE #3
  (non-atomic delete-then-create), ISSUE #4 (no concurrent-create guard),
  and ISSUE #8 (reconnect/duplicate resume race) where fixes are too invasive
  to land without new runtime primitives
- chat.el: validate session_id exists via session_exists() before entering
  agentic_loop; unknown session_ids now return a 404-style error instead of
  silently starting a fresh empty session (ISSUE #6/#7)
2026-06-22 11:58:33 -05:00
will.anderson ae9a139440 fix(reliability): safety-resilience — bell augmentation, safe mode, dedup logging, tab escaping, handle_chat coverage
Neuron Soul CI / build (pull_request) Has been cancelled
2026-06-22 11:57:43 -05:00
5 changed files with 443 additions and 38 deletions
+210 -20
View File
@@ -347,11 +347,26 @@ fn clean_llm_response(s: String) -> String {
}
// conv_history_persist save conversation history to engram for cross-restart continuity.
// Stores as a Conversation node. Overwrites by using consistent label "conv:history".
// Delete-before-write under label "conv:history" prevents unbounded node accumulation (issue #11).
fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "") { return "" }
if str_eq(hist, "[]") { return "" }
let ts: Int = time_now()
// Delete any existing conv:history nodes before writing to avoid accumulation.
let old_hist_results: String = engram_search_json("conv:history", 3)
let old_hist_ok: Bool = !str_eq(old_hist_results, "") && !str_eq(old_hist_results, "[]")
if old_hist_ok {
let ohr_total: Int = json_array_len(old_hist_results)
let ohr_i: Int = 0
while ohr_i < ohr_total {
let ohr_node: String = json_array_get(old_hist_results, ohr_i)
let ohr_label: String = json_get(ohr_node, "label")
let ohr_id: String = json_get(ohr_node, "id")
if str_eq(ohr_label, "conv:history") && !str_eq(ohr_id, "") {
engram_forget(ohr_id)
}
let ohr_i = ohr_i + 1
}
}
let tags: String = "[\"conv-history\",\"persistent\"]"
let discard: String = engram_node_full(
hist, "Conversation", "conv:history",
@@ -400,18 +415,25 @@ fn handle_chat(body: String) -> String {
// Cross-session affective context: on session start (no history yet), check engram
// for recent distress signals within 72h and prepend a care directive if found.
// Fixes issue #6: soul_affective_context is pre-loaded at boot use it first to
// avoid a redundant engram search and to make the boot-time state key functional.
let affective_prefix: String = if hist_len == 0 {
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
let now_ts: Int = time_now()
let cutoff: Int = now_ts - 259200
let found_recent: Bool = if has_nodes {
let dn0: String = json_array_get(distress_nodes, 0)
let ts0_raw: String = json_get(dn0, "created_at")
let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
ts0 > cutoff
} else { false }
let soul_aff_ctx: String = state_get("soul_affective_context")
let found_recent: Bool = if !str_eq(soul_aff_ctx, "") {
true
} else {
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
let now_ts: Int = time_now()
let cutoff: Int = now_ts - 259200
if has_nodes {
let dn0: String = json_array_get(distress_nodes, 0)
let ts0_raw: String = json_get(dn0, "created_at")
let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
ts0 > cutoff
} else { false }
}
if found_recent {
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
} else { "" }
@@ -429,6 +451,49 @@ fn handle_chat(body: String) -> String {
let profile_ok: Bool = !str_eq(profile_nodes, "") && !str_eq(profile_nodes, "[]")
let work_ok: Bool = !str_eq(work_nodes, "") && !str_eq(work_nodes, "[]")
// Load the previous session summary. Search by label text + type, then filter by
// exact label match. Fallback: broader vector search for SessionSummary nodes.
// Fixes issue #2: prev session summary was never loaded at startup.
// Fixes issue #2b (phantom engram_get_node_by_label replaced with engram_search_json).
let sum_search_nodes: String = engram_search_json("session:summary SessionSummary", 5)
let sum_search_ok: Bool = !str_eq(sum_search_nodes, "") && !str_eq(sum_search_nodes, "[]")
let prev_sum_node_content: String = if sum_search_ok {
let ss_total: Int = json_array_len(sum_search_nodes)
let ssi: Int = 0
let found_content: String = ""
while ssi < ss_total {
let ss_node: String = json_array_get(sum_search_nodes, ssi)
let ss_label: String = json_get(ss_node, "label")
let ss_type: String = json_get(ss_node, "node_type")
let ss_content: String = json_get(ss_node, "content")
let found_content = if str_eq(ss_label, "session:summary") && str_eq(ss_type, "SessionSummary") && !str_eq(ss_content, "") {
if str_eq(found_content, "") { ss_content } else { found_content }
} else { found_content }
let ssi = ssi + 1
}
found_content
} else { "" }
// Check state first: soul.el pre-loads this at boot (soul_prev_session_summary) fixes issue #5.
let soul_cached_sum: String = state_get("soul_prev_session_summary")
let prev_summary_raw: String = if !str_eq(soul_cached_sum, "") {
soul_cached_sum
} else if !str_eq(prev_sum_node_content, "") {
prev_sum_node_content
} else {
let sum_nodes: String = engram_search_json("SessionSummary previous-session", 3)
let sum_ok: Bool = !str_eq(sum_nodes, "") && !str_eq(sum_nodes, "[]")
if sum_ok {
let sn0: String = json_array_get(sum_nodes, 0)
let stype: String = json_get(sn0, "node_type")
let scontent: String = json_get(sn0, "content")
if str_eq(stype, "SessionSummary") && !str_eq(scontent, "") { scontent } else { "" }
} else { "" }
}
let has_prev_summary: Bool = !str_eq(prev_summary_raw, "")
let prev_summary_snip: String = if str_len(prev_summary_raw) > 400 {
str_slice(prev_summary_raw, 0, 400)
} else { prev_summary_raw }
// Extract content fields and render as bullet points (one per node, first 120 chars).
let profile_bullets: String = if profile_ok {
let pn: Int = json_array_len(profile_nodes)
@@ -476,15 +541,19 @@ fn handle_chat(body: String) -> String {
let has_profile: Bool = !str_eq(profile_bullets, "")
let has_work: Bool = !str_eq(work_bullets, "")
let preload: String = if has_profile || has_work {
let preload: String = if has_profile || has_work || has_prev_summary {
let summary_section: String = if has_prev_summary {
"[PREVIOUS SESSION - what we discussed last time]\n" + prev_summary_snip
} else { "" }
let profile_section: String = if has_profile {
"[USER CONTEXT from memory]\n" + profile_bullets
"[USER CONTEXT - from memory]\n" + profile_bullets
} else { "" }
let work_section: String = if has_work {
"[ACTIVE WORK from memory]\n" + work_bullets
"[ACTIVE WORK - from memory]\n" + work_bullets
} else { "" }
let sep_sp: String = if has_prev_summary && (has_profile || has_work) { "\n\n" } else { "" }
let sep_pw: String = if has_profile && has_work { "\n\n" } else { "" }
"\n\n" + profile_section + sep_pw + work_section
"\n\n" + summary_section + sep_sp + profile_section + sep_pw + work_section
} else { "" }
preload
} else { "" }
@@ -498,6 +567,10 @@ fn handle_chat(body: String) -> String {
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
// ISSUE 9: add safety_augment_system to primary /api/chat path.
// handle_chat was the only LLM path missing bell directive injection.
let full_system = safety_augment_system(full_system, message)
let raw_response: String = llm_call_system(model, full_system, message)
let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
@@ -522,6 +595,14 @@ fn handle_chat(body: String) -> String {
state_set("conv_history", final_hist)
conv_history_persist(final_hist)
// Automatic session-end summary: write/overwrite the SessionSummary node on each turn
// so process restarts always have a continuity snapshot (no shutdown hook needed).
// Uses autogenerate (no LLM) so it is cheap the node is overwritten not appended.
let auto_sum: String = session_summary_autogenerate(final_hist)
if !str_eq(auto_sum, "") {
let discard_sum: String = session_summary_write(auto_sum)
}
let activation_nodes: String = engram_activate_json(message, 2)
let act_ok: Bool = !str_eq(activation_nodes, "") && !str_eq(activation_nodes, "[]")
let act_out: String = if act_ok { activation_nodes } else { "[]" }
@@ -964,13 +1045,15 @@ fn handle_chat_agentic(body: String) -> String {
// L1 safety screen agentic path must pass the same gate as layered_cycle.
// Hard bell: return the crisis response immediately, do not enter the agentic loop.
let history: String = state_get("conversation_history")
// Fix(issue #9): "conversation_history" key was never written; history lives under "conv_history".
// Old key caused history-amplification in safety_screen to always receive "" on agentic path.
let history: String = state_get("conv_history")
let screen_result: String = safety_screen(message, history)
let screen_action: String = json_get(screen_result, "action")
if str_eq(screen_action, "hard_bell") {
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
}
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
@@ -978,8 +1061,30 @@ fn handle_chat_agentic(body: String) -> String {
// Thread-aware activation: same logic as handle_chat.
// Use the session's or global history to anchor short messages to the thread.
let req_session: String = json_get(body, "session_id")
// ISSUE #6/#7: validate that the session_id actually exists before proceeding.
// Without this check the loop silently treats any unknown/fabricated session_id
// as a fresh session history loads as empty and no error is returned to the caller.
// Only validate when a session_id is explicitly provided; anonymous calls
// (no session_id) continue to work for backward compatibility.
let session_valid: Bool = if str_eq(req_session, "") {
true
} else {
session_exists(req_session)
}
if !session_valid {
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
}
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
let agentic_hist: String = state_get(hist_key)
// Fall back to engram (via session_hist_load) when state is cold fixes issue #4:
// named-session history written under session:messages:SESSION_ID was never read back.
let agentic_hist_state: String = state_get(hist_key)
let agentic_hist: String = if str_eq(agentic_hist_state, "") && !str_eq(req_session, "") {
let loaded: String = session_hist_load(req_session)
if !str_eq(loaded, "") { state_set(hist_key, loaded) }
if str_eq(loaded, "") { conv_history_load() } else { loaded }
} else { agentic_hist_state }
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
let ag_is_cont: Bool = str_len(message) < 50 && agentic_hist_len > 0
let ag_last_entry: String = if ag_is_cont { json_array_get(agentic_hist, agentic_hist_len - 1) } else { "" }
@@ -1022,6 +1127,23 @@ fn handle_chat_agentic(body: String) -> String {
let updated2: String = hist_append(updated, "assistant", reply_text)
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
state_set(hist_key, trimmed)
// Persist to engram for cross-restart continuity.
// Named sessions use session_hist_save (session:messages:SESSION_ID label) so that
// session_hist_load can recover them on the next restart fixes issue #4.
// The old conv:history:SESSION_ID label was a dead write (never read back).
if str_eq(hist_key, "conv_history") {
conv_history_persist(trimmed)
} else {
if !str_eq(trimmed, "") && !str_eq(trimmed, "[]") {
session_hist_save(req_session, trimmed)
}
}
// Write automatic session summary so cross-session continuity is maintained
// on the agentic path too fixes issue #7.
let ag_auto_sum: String = session_summary_autogenerate(trimmed)
if !str_eq(ag_auto_sum, "") {
let discard_ag_sum: String = session_summary_write(ag_auto_sum)
}
true
} else { false }
@@ -1475,6 +1597,74 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
return "{\"response\":\"" + safe_text + "\",\"cgi_id\":\"" + cgi_id + "\",\"tools_used\":" + eff_tools + "}"
}
// session_summary_write write or overwrite the SessionSummary node in engram.
// Uses delete-before-write so there is always exactly one "session:summary" node.
// This is what session_preload at next startup reads to know what was discussed.
fn session_summary_write(summary_text: String) -> String {
if str_eq(summary_text, "") { return "" }
let safe_text: String = str_replace(summary_text, "\"", "'")
let trimmed: String = if str_len(safe_text) > 800 { str_slice(safe_text, 0, 800) } else { safe_text }
let ts: Int = time_now()
let ts_str: String = int_to_str(ts)
let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str
// Delete old node before writing so duplicate label nodes don't accumulate.
// engram_get_node_by_label doesn't exist search by label text and filter by exact match.
let old_search: String = engram_search_json("session:summary SessionSummary", 5)
let old_search_ok: Bool = !str_eq(old_search, "") && !str_eq(old_search, "[]")
if old_search_ok {
let os_total: Int = json_array_len(old_search)
let osi: Int = 0
while osi < os_total {
let os_node: String = json_array_get(old_search, osi)
let os_label: String = json_get(os_node, "label")
let os_id: String = json_get(os_node, "id")
if str_eq(os_label, "session:summary") && !str_eq(os_id, "") {
engram_forget(os_id)
}
let osi = osi + 1
}
}
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
let node_id: String = engram_node_full(
content, "SessionSummary", "session:summary",
el_from_float(0.85), el_from_float(0.85), el_from_float(1.0),
"Episodic", tags
)
if str_eq(node_id, "") {
println("[chat] session_summary_write: engram write failed — summary node lost")
return ""
}
println("[chat] session_summary_write: wrote SessionSummary (" + int_to_str(str_len(content)) + " chars) -> " + node_id)
return node_id
}
// session_summary_autogenerate build a minimal summary from conversation history without LLM.
// Extracts user message snippets (first 80 chars each, up to 5 turns).
// Used as the automatic session-end hook so every turn produces a continuity snapshot.
fn session_summary_autogenerate(hist: String) -> String {
if str_eq(hist, "") { return "" }
if str_eq(hist, "[]") { return "" }
let total: Int = json_array_len(hist)
if total == 0 { return "" }
let snippets: String = ""
let count: Int = 0
let i: Int = 0
while i < total && count < 5 {
let entry: String = json_array_get(hist, i)
let role: String = json_get(entry, "role")
let msg: String = json_get(entry, "content")
let snip: String = if str_len(msg) > 80 { str_slice(msg, 0, 80) } else { msg }
// Mutations at top level of while body via if-expressions inner if blocks don't escape scope.
let snippets = if str_eq(role, "user") && !str_eq(snip, "") {
if str_eq(snippets, "") { snip } else { snippets + "; " + snip }
} else { snippets }
let count = if str_eq(role, "user") && !str_eq(snip, "") { count + 1 } else { count }
let i = i + 1
}
if str_eq(snippets, "") { return "" }
return "Session covered: " + snippets
}
fn auto_persist(req: String, resp: String) -> Void {
let message: String = json_get(req, "message")
let reply: String = json_get(resp, "response")
+8 -8
View File
@@ -1,4 +1,5 @@
import "memory.el"
import "chat.el"
// neuron-api.el Native Neuron cognitive API handlers.
//
@@ -654,14 +655,13 @@ fn handle_api_consolidate(body: String) -> String {
engram_save(snap)
}
if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let discard: 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
)
// Use session_summary_write to ensure delete-before-write semantics:
// prevents stale SessionSummary accumulation across sessions (issue #11).
// session_summary_write handles label indexing, trimming, and dedup.
let sum_id: String = session_summary_write(summary)
if str_eq(sum_id, "") {
println("[api] consolidate: session_summary_write failed — summary not persisted")
}
}
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
}
+15 -3
View File
@@ -144,17 +144,21 @@ 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: also escape tab chars to prevent JSON envelope corruption.
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: also escape tab chars (see soft_bell branch above).
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 +199,8 @@ 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: fallback log when engram write fails silently.
let node_id: String = engram_node_full(
content,
"BellEvent",
"bell:" + level,
@@ -205,6 +210,9 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
"Episodic",
tags
)
if str_eq(node_id, "") {
println("[safety] WARN: bell engram write failed -- " + content)
}
return ""
}
@@ -235,6 +243,10 @@ 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\",\"highest structure\",\"tallest building\",\"tallest structure\",\"highest building\",\"bridge near me\",\"overpass near\",\"rooftop near\"]"
}
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
// json_array_len of malformed input returns 0, silently skipping all checks.
// Caching requires language-level static const arrays -- not in current EL.
// Migrate to const arrays 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) ────────────────────
+97 -1
View File
@@ -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.
let existing_idx: String = state_get("session_index")
let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}"
let new_idx: String = if str_eq(existing_idx, "") {
@@ -73,6 +120,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).
@@ -222,13 +283,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,6 +424,9 @@ fn session_hist_load(session_id: String) -> String {
// session_hist_save persist message history for a session to state and engram.
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) }
@@ -413,6 +491,16 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
}
// 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) }
@@ -506,6 +594,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()
+113 -6
View File
@@ -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",
@@ -166,6 +162,48 @@ fn load_identity_context() -> Void {
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
}
}
// Cross-session affective context: query engram for recent distress/crisis signals.
// Broadened query includes session:emotional-summary and BellEvent tags (issue #10):
// the old keywords-only search missed these nodes when their content lacked exact phrases.
// 7-day recency window applied via the "ts" field embedded in BellEvent content.
let affective_raw: String = engram_search_json("distress crisis upset hopeless session:emotional-summary BellEvent bell:hard bell:soft", 5)
let affective_ok: Bool = !str_eq(affective_raw, "") && !str_eq(affective_raw, "[]")
if affective_ok {
let ts_now: Int = time_now()
let ts_cutoff: Int = ts_now - 604800
let aff_total: Int = json_array_len(affective_raw)
let aff_ctx: String = ""
let ai: Int = 0
while ai < aff_total {
let aff_node: String = json_array_get(affective_raw, ai)
let aff_content: String = json_get(aff_node, "content")
// Try multiple timestamp fields: "ts" (embedded), "created_at", "updated_at"
let aff_ts_str: String = json_get(aff_node, "ts")
let aff_ts_str2: String = if str_eq(aff_ts_str, "") { json_get(aff_node, "created_at") } else { aff_ts_str }
// Also try embedded " | ts:NNN" format used in BellEvent content
let ts_marker: String = " | ts:"
let ts_pos: Int = str_index_of(aff_content, ts_marker)
let aff_ts_embedded: String = if ts_pos >= 0 {
let ts_start: Int = ts_pos + str_len(ts_marker)
let rest: String = str_slice(aff_content, ts_start, str_len(aff_content))
let next_sep: Int = str_index_of(rest, " | ")
if next_sep < 0 { rest } else { str_slice(rest, 0, next_sep) }
} else { "" }
let eff_ts_str: String = if !str_eq(aff_ts_embedded, "") { aff_ts_embedded } else { aff_ts_str2 }
let aff_ts: Int = if str_eq(eff_ts_str, "") { ts_now } else { str_to_int(eff_ts_str) }
let is_recent: Bool = aff_ts >= ts_cutoff
let snip: String = if str_len(aff_content) > 200 { str_slice(aff_content, 0, 200) } else { aff_content }
let aff_ctx = if is_recent && !str_eq(snip, "") {
if str_eq(aff_ctx, "") { snip } else { aff_ctx + "\n" + snip }
} else { aff_ctx }
let ai = ai + 1
}
if !str_eq(aff_ctx, "") {
state_set("soul_affective_context", aff_ctx)
println("[soul] cross-session 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 +275,59 @@ fn emit_session_start_event() -> Void {
}
let ts: Int = time_now()
// Load previous session summary at boot stash in state for session_preload.
// Search by label text + type, filter by exact label match to avoid false positives.
// engram_get_node_by_label is not a runtime builtin; engram_search_json is used instead.
let sum_boot_search: String = engram_search_json("session:summary SessionSummary", 5)
let sum_boot_ok: Bool = !str_eq(sum_boot_search, "") && !str_eq(sum_boot_search, "[]")
let prev_sum_content: String = if sum_boot_ok {
let sbs_total: Int = json_array_len(sum_boot_search)
let sbs_i: Int = 0
let sbs_found: String = ""
while sbs_i < sbs_total {
let sbs_node: String = json_array_get(sum_boot_search, sbs_i)
let sbs_label: String = json_get(sbs_node, "label")
let sbs_type: String = json_get(sbs_node, "node_type")
let sbs_content: String = json_get(sbs_node, "content")
let sbs_found = if str_eq(sbs_label, "session:summary") && str_eq(sbs_type, "SessionSummary") && !str_eq(sbs_content, "") {
if str_eq(sbs_found, "") { sbs_content } else { sbs_found }
} else { sbs_found }
let sbs_i = sbs_i + 1
}
if str_eq(sbs_found, "") {
let sum_fb: String = engram_search_json("SessionSummary previous-session", 2)
let sum_fb_ok: Bool = !str_eq(sum_fb, "") && !str_eq(sum_fb, "[]")
if sum_fb_ok {
let sfn: String = json_array_get(sum_fb, 0)
let sftype: String = json_get(sfn, "node_type")
let sfcontent: String = json_get(sfn, "content")
if str_eq(sftype, "SessionSummary") && !str_eq(sfcontent, "") { sfcontent } else { "" }
} else { "" }
} else { sbs_found }
} else {
let sum_fb2: String = engram_search_json("SessionSummary previous-session", 2)
let sum_fb2_ok: Bool = !str_eq(sum_fb2, "") && !str_eq(sum_fb2, "[]")
if sum_fb2_ok {
let sfn2: String = json_array_get(sum_fb2, 0)
let sftype2: String = json_get(sfn2, "node_type")
let sfcontent2: String = json_get(sfn2, "content")
if str_eq(sftype2, "SessionSummary") && !str_eq(sfcontent2, "") { sfcontent2 } 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,7 +336,7 @@ 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.
@@ -265,19 +350,31 @@ fn layered_cycle(raw_input: String) -> String {
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")
}
@@ -312,6 +409,16 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to")
}
// ISSUE 1: pre-LLM bell augmentation for layered_cycle path.
// safety_augment_system appends soft/hard directive to system prompt when bell fires,
// ensuring LLM processes message WITH the safety directive -- not just post-output gate.
// Stored in state as "layered_cycle_safety_system_addendum" for imprint_respond to use.
// TODO: wire directly when imprint_respond gains system_override param (imprint.el change).
// ISSUE 3 TODO: no semantic crisis detection. Keyword-only means signals that evade
// the phrase list pass with zero augmentation. Semantic layer = separate decision.
let augmented_addendum: String = safety_augment_system("", raw_input)
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
// L3: imprint responds
let output: String = imprint_respond(aligned, imprint_id)