Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson 0c5b966773 fix(chat): fix auto_persist timestamp extraction and bell label uniqueness
Neuron Soul CI / build (pull_request) Has been cancelled
- engram_compile: BellEvent nodes do not carry created_at in the engram
  node JSON; extract the unix timestamp from the embedded ' | ts:NNNNN'
  pattern in the content string instead. Fall back to created_at/updated_at
  if the marker is absent. Guard str_to_int against empty string so the 72h
  recency check never silently treats every node as epoch-0 stale.

- auto_persist: append the current unix timestamp to the BellEvent label
  ('bell:soft:1749876543') to make it unique per turn. The previous label
  ('bell:soft') was the same for every soft bell, causing engram to treat
  all subsequent writes as updates to the same node.
2026-06-22 12:09:00 -05:00
will.anderson b2008f4894 feat(memory): emotional salience tagging and cross-session distress persistence
Neuron Soul CI / build (pull_request) Successful in 5m36s
- 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.
2026-06-22 11:23:15 -05:00
2 changed files with 226 additions and 127 deletions
+183 -30
View File
@@ -40,9 +40,43 @@ 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)
// created_at is not present in engram node JSON for BellEvent nodes.
// Extract the timestamp embedded in the content string as " | ts:NNNNN".
// Fall back to created_at / updated_at JSON fields if the marker is absent.
let bn_content: String = json_get(bn0, "content")
let ts_marker: String = " | ts:"
let ts_pos: Int = str_index_of(bn_content, ts_marker)
let bn_ts_raw: String = if ts_pos >= 0 {
let ts_start: Int = ts_pos + str_len(ts_marker)
let rest: String = str_slice(bn_content, ts_start, str_len(bn_content))
let next_sep: Int = str_index_of(rest, " | ")
if next_sep < 0 { rest } else { str_slice(rest, 0, next_sep) }
} else {
let ca: String = json_get(bn0, "created_at")
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
}
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { 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 +142,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 +297,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
}
@@ -631,38 +730,12 @@ fn handle_chat_agentic(body: String) -> String {
return "{\"error\":\"message required\",\"reply\":\"\"}"
}
// Workspace scope (#23): the desktop UI sends the user-chosen Agent Workspace root
// on every agentic request. Persist it to state so agent_workspace_root() and the
// path/command tool guards that read it confine this turn's file/command tools to
// that subtree. The UI is the source of truth per request: empty means unscoped (the
// backward-compatible default), and it also lets agent_workspace_root() fall through
// to the NEURON_AGENT_ROOT env when no root is sent. FLAGGED FOR REVIEW: setting
// state from the body each turn (vs. only-when-nonempty) so clearing the folder in
// the UI un-scopes confirm this is the intended ownership model.
let ws_root: String = json_get(body, "agent_workspace_root")
state_set("agent_workspace_root", ws_root)
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
// 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)
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
@@ -1161,14 +1234,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,
@@ -1178,6 +1265,72 @@ 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_ts_str: String = int_to_str(time_now())
let bell_label: String = "bell:" + bell_level + ":" + bell_ts_str
let bell_node_id: String = engram_node_full(
bell_content,
"BellEvent",
bell_label,
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.
+43 -97
View File
@@ -36,49 +36,7 @@ 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()
@@ -97,13 +55,8 @@ 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, "") {
@@ -120,20 +73,6 @@ 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).
@@ -283,27 +222,13 @@ 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\"}"
@@ -424,9 +349,6 @@ 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) }
@@ -446,19 +368,51 @@ 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.
//
// 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) }
@@ -552,14 +506,6 @@ 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()