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.
This commit is contained in:
@@ -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 "" }
|
||||
|
||||
+3
-1
@@ -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 + "\"}"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user