From b2008f4894c615bcb4c4b202bd2b6fe2e9c1a2b2 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 11:23:15 -0500 Subject: [PATCH] feat(memory): emotional salience tagging and cross-session distress persistence - auto_persist: detect bell level (soft/hard) on every user message using safety_detect_bell_level; write a dedicated BellEvent engram node with calibrated salience alongside the Conversation node when a bell fires. Tag the Conversation node with bell:soft/bell:hard and 'affective' for direct discovery without scanning all chat nodes. - auto_persist: track per-session bell count, dominant level, and last signal in state (session_bell_count/level/signal keys) so downstream functions can act on the emotional history without re-scanning engram. - engram_compile: include the top-1 most recent BellEvent node within 72h in every context build. Distress context from earlier turns (same or recent session) automatically travels into all subsequent LLM calls. - hist_trim_with_bell_guard: replace hist_trim at the handle_chat call site. Before evicting the oldest turn from the 20-turn window, inspect the user message for bell signals. If a bell was present, write a preservation BellEvent to engram before dropping the turn so the full message survives the rolling window. - session_hist_save: after writing the history node, check session bell counters. On the first save where bell_count > 0, write a session:emotional-summary BellEvent node with distress signal, count, and dominant level. A state flag prevents duplicate writes on subsequent saves in the same session. --- chat.el | 171 ++++++++++++++++++++++++++++++++++++++++++++++++++-- sessions.el | 42 +++++++++++++ 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/chat.el b/chat.el index 913259d..8a8e0ca 100644 --- a/chat.el +++ b/chat.el @@ -40,9 +40,29 @@ fn engram_compile(intent: String) -> String { "" } + // Affective context: always include the most recent high-emotion memory if one + // exists within 72 hours. This ensures continuity of care across turns — when + // the user was in distress earlier in the session (or recently), that context + // travels into every subsequent LLM call so the response register stays aware. + // We search for BellEvent nodes specifically; these are written by auto_persist + // when safety_detect_bell_level fires. The 72h window (259200 seconds) is wide + // enough to span a multi-session day without pulling ancient history. + let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3) + let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]") + let now_ts: Int = time_now() + let cutoff_ts: Int = now_ts - 259200 + let recent_bell: String = if bell_ok { + let bn0: String = json_array_get(bell_nodes, 0) + let bn_ts_raw: String = json_get(bn0, "created_at") + let bn_ts: Int = str_to_int(bn_ts_raw) + if bn_ts > cutoff_ts { bn0 } else { "" } + } else { "" } + let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" } + let sep1: String = if !str_eq(act_part, "") && !str_eq(srch_part, "") { "\n" } else { "" } let sep2: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "")) && !str_eq(scan_part, "") { "\n" } else { "" } - let ctx: String = act_part + sep1 + srch_part + sep2 + scan_part + let sep3: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "") || !str_eq(scan_part, "")) && !str_eq(affective_part, "") { "\n" } else { "" } + let ctx: String = act_part + sep1 + srch_part + sep2 + scan_part + sep3 + affective_part if str_eq(ctx, "") { return "" } @@ -108,6 +128,69 @@ fn hist_trim(hist: String) -> String { return hist } +// hist_trim_with_bell_guard — trim the history window exactly as hist_trim does, but +// before dropping the oldest user/assistant pair check whether the user turn triggered +// a bell event. If it did, write a preservation node to engram so the distress exchange +// survives the 20-turn window. The LLM window drops it; engram retains it permanently +// and engram_compile will surface it again via the affective context path. +fn hist_trim_with_bell_guard(hist: String) -> String { + // Extract the first turn (should be a user message) to inspect it. + let inner: String = str_slice(hist, 1, str_len(hist) - 1) + let marker: String = "{\"role\":" + let i1: Int = str_index_of(inner, marker) + // i1 is the start of the first entry within inner. + // Find where the second entry begins to delimit the first entry's JSON. + let tail1: String = str_slice(inner, i1 + 1, str_len(inner)) + let i2: Int = str_index_of(tail1, marker) + // The first entry spans from i1 to (i1 + 1 + i2 - 1) within inner. + let first_entry_raw: String = if i2 > 0 { + str_slice(inner, i1, i1 + 1 + i2 - 1) + } else { + str_slice(inner, i1, str_len(inner)) + } + let first_role: String = json_get(first_entry_raw, "role") + let first_content: String = json_get(first_entry_raw, "content") + + // Only inspect user turns — assistant content doesn't carry bell signals. + let bell_level: String = if str_eq(first_role, "user") { + safety_detect_bell_level(first_content) + } else { + "none" + } + + // If the turn being evicted triggered a bell, preserve it to engram. + // This is distinct from the BellEvent written by auto_persist: that node + // carries a short summary. This node carries the full exchange content so + // it is recoverable for clinical/continuity review. + if !str_eq(bell_level, "none") { + let ts: Int = time_now() + let ts_str: String = int_to_str(ts) + let safe_content: String = str_replace(first_content, "\"", "'") + let preserve_content: String = "PRESERVED_BELL:" + bell_level + + " | evicted_at:" + ts_str + + " | message:" + safe_content + let preserve_tags: String = "[\"bell-history\",\"bell:" + bell_level + "\",\"evicted\",\"affective\",\"BellEvent\"]" + let discard: String = engram_node_full( + preserve_content, + "BellEvent", + "bell:" + bell_level + ":preserved", + el_from_float(0.9), + el_from_float(0.9), + el_from_float(1.0), + "Episodic", + preserve_tags + ) + } + + // Now perform the standard trim (drop oldest 2 entries = 1 user + 1 assistant pair). + let tail2: String = str_slice(tail1, i2 + 1, str_len(tail1)) + let i3: Int = str_index_of(tail2, marker) + if i3 >= 0 { + return "[" + str_slice(tail2, i3, str_len(tail2)) + "]" + } + return hist +} + // clean_llm_response — strips GPT-2 BPE byte-to-unicode artifacts that vLLM // emits when the tokenizer hasn't decoded back to raw bytes. // @@ -200,8 +283,10 @@ fn handle_chat(body: String) -> String { let updated_hist: String = hist_append(stored_hist, "user", message) let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response) + // Use bell-guarded trim: if the evicted turn triggered a bell event, it is + // preserved to engram before being dropped from the in-memory window. let final_hist: String = if json_array_len(updated_hist2) > 20 { - hist_trim(updated_hist2) + hist_trim_with_bell_guard(updated_hist2) } else { updated_hist2 } @@ -1135,14 +1220,28 @@ fn auto_persist(req: String, resp: String) -> Void { let safe_msg: String = str_replace(message, "\"", "'") let safe_reply: String = str_replace(reply2, "\"", "'") + // Detect emotional salience before persisting. safety_detect_bell_level uses the + // same phrase lists as the safety layer (safety.el), so the classification is + // consistent with what safety_screen already evaluated for this turn. + let bell_level: String = safety_detect_bell_level(message) + let is_bell: Bool = !str_eq(bell_level, "none") + + // Tag the Conversation node with bell metadata when distress is present so + // subsequent affective queries (e.g. engram_compile) can find this exchange. + let tags: String = if is_bell { + "[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]" + } else { + "[\"Conversation\",\"chat\",\"timestamped\"]" + } + let content: String = "{\"q\":\"" + safe_msg + "\"" + ",\"a\":\"" + safe_reply + "\"" + ",\"created_at\":" + ts_str + ",\"source\":\"chat\"" + + ",\"bell\":\"" + bell_level + "\"" + ",\"label\":\"chat:" + ts_str + "\"}" - let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]" - engram_node_full( + let conv_node_id: String = engram_node_full( content, "Conversation", "chat:" + ts_str, @@ -1152,6 +1251,70 @@ fn auto_persist(req: String, resp: String) -> Void { "Episodic", tags ) + + // When a bell fires, write a dedicated BellEvent node in addition to the + // Conversation node. This makes distress moments directly findable by label + // ("bell:soft" / "bell:hard") without having to scan all Conversation nodes. + // The BellEvent carries higher salience so engram_compile pulls it into context. + // The message content is truncated to 120 chars — enough signal, not a full dump. + if is_bell { + let summary: String = if str_len(message) > 120 { str_slice(message, 0, 120) } else { message } + let safe_summary: String = str_replace(summary, "\"", "'") + let bell_content: String = "BELL:" + bell_level + + " | ts:" + ts_str + + " | summary:" + safe_summary + + // bell:hard gets peak salience; bell:soft is slightly lower. + let sal_a: String = if str_eq(bell_level, "hard") { el_from_float(0.98) } else { el_from_float(0.88) } + let sal_b: String = if str_eq(bell_level, "hard") { el_from_float(0.98) } else { el_from_float(0.88) } + let sal_c: String = if str_eq(bell_level, "hard") { el_from_float(1.0) } else { el_from_float(0.95) } + + let bell_tags: String = "[\"safety\",\"bell\",\"bell:" + bell_level + "\",\"affective\",\"BellEvent\"]" + let bell_node_id: String = engram_node_full( + bell_content, + "BellEvent", + "bell:" + bell_level, + sal_a, + sal_b, + sal_c, + "Episodic", + bell_tags + ) + + // Increment session-level bell counter so session_hist_save knows whether + // any bell fired during this session when writing a boundary summary. + let sess_id: String = json_get(req, "session_id") + let bell_key: String = if str_eq(sess_id, "") { + "session_bell_count" + } else { + "session_bell_count:" + sess_id + } + let prior_count: String = state_get(bell_key) + let prior_n: Int = if str_eq(prior_count, "") { 0 } else { str_to_int(prior_count) } + state_set(bell_key, int_to_str(prior_n + 1)) + + // Also record the highest bell level seen this session so the boundary + // summary can classify the session correctly (hard takes precedence). + let level_key: String = if str_eq(sess_id, "") { + "session_bell_level" + } else { + "session_bell_level:" + sess_id + } + let prior_level: String = state_get(level_key) + let new_level: String = if str_eq(bell_level, "hard") { "hard" } else { + if str_eq(prior_level, "hard") { "hard" } else { "soft" } + } + state_set(level_key, new_level) + + // Stash a short signal summary for the boundary node (last bell wins for + // the one-liner; the full history is in per-bell BellEvent nodes). + let signal_key: String = if str_eq(sess_id, "") { + "session_bell_signal" + } else { + "session_bell_signal:" + sess_id + } + state_set(signal_key, safe_summary) + } } // strengthen_chat_nodes — strengthen the engram nodes that were activated during a chat. diff --git a/sessions.el b/sessions.el index fac9a79..858aefb 100644 --- a/sessions.el +++ b/sessions.el @@ -368,6 +368,48 @@ fn session_hist_save(session_id: String, hist: String) -> Void { 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") + } + } } // session_update_meta_timestamp — update the updated_at field in the session:meta node.