From 95cb49a8b03441527507dd5c6921b4d0829555b7 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 13:39:46 -0500 Subject: [PATCH] fix(cross-session-continuity): resolve 11 issues from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat.el | 154 ++++++++++++++++++++++++++++++++++---------------- neuron-api.el | 4 +- soul.el | 49 +++++++++++----- 3 files changed, 145 insertions(+), 62 deletions(-) diff --git a/chat.el b/chat.el index 6fdc2dd..8966cb2 100644 --- a/chat.el +++ b/chat.el @@ -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,15 +451,36 @@ 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. Primary: label-based fetch (stable, written - // by session_summary_write). Fallback: vector search for SessionSummary 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. - let prev_sum_node: String = engram_get_node_by_label("session:summary") - let prev_sum_label_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null") - let prev_summary_raw: String = if prev_sum_label_ok { - json_get(prev_sum_node, "content") + // 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 session:summary previous-session", 3) + 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) @@ -500,13 +543,13 @@ fn handle_chat(body: String) -> String { let has_work: Bool = !str_eq(work_bullets, "") 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 + "[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 { "" } @@ -1010,7 +1053,7 @@ fn handle_chat_agentic(body: String) -> String { 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 } @@ -1034,7 +1077,14 @@ fn handle_chat_agentic(body: String) -> String { } 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 { "" } @@ -1078,23 +1128,22 @@ fn handle_chat_agentic(body: String) -> String { 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 get session-scoped labels, fixing ephemeral-only limitation (issue #4). + // 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, "[]") { - let sess_hist_label: String = "conv:history:" + req_session - let sess_hist_tags: String = "[\"session-history\",\"persistent\"]" - let sess_hist_id: String = engram_node_full( - trimmed, "Conversation", sess_hist_label, - el_from_float(0.6), el_from_float(0.7), el_from_float(0.8), - "Episodic", sess_hist_tags - ) - if str_eq(sess_hist_id, "") { - println("[chat] agentic: named session history persist failed for session=" + req_session) - } + 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 } @@ -1559,12 +1608,20 @@ fn session_summary_write(summary_text: String) -> String { 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. - let old_node: String = engram_get_node_by_label("session:summary") - let old_ok: Bool = !str_eq(old_node, "") && !str_eq(old_node, "null") - if old_ok { - let old_id: String = json_get(old_node, "id") - if !str_eq(old_id, "") { - engram_forget(old_id) + // 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\"]" @@ -1595,12 +1652,13 @@ fn session_summary_autogenerate(hist: String) -> String { while i < total && count < 5 { let entry: String = json_array_get(hist, i) let role: String = json_get(entry, "role") - if str_eq(role, "user") { - let msg: String = json_get(entry, "content") - let snip: String = if str_len(msg) > 80 { str_slice(msg, 0, 80) } else { msg } - let snippets = if str_eq(snippets, "") { snip } else { snippets + "; " + snip } - let count = count + 1 - } + 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 "" } diff --git a/neuron-api.el b/neuron-api.el index fb95c4f..9398538 100644 --- a/neuron-api.el +++ b/neuron-api.el @@ -1,4 +1,5 @@ import "memory.el" +import "chat.el" // neuron-api.el — Native Neuron cognitive API handlers. // @@ -661,5 +662,6 @@ fn handle_api_consolidate(body: String) -> String { if str_eq(sum_id, "") { println("[api] consolidate: session_summary_write failed — summary not persisted") } - } return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}" + } + return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}" } diff --git a/soul.el b/soul.el index 5033420..5ad5fb1 100644 --- a/soul.el +++ b/soul.el @@ -275,20 +275,43 @@ 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") + // 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_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 { "" } + 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" }