From 795b32ad1a58481ce13de26e778e9f66f0d90e4d Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 13:00:17 -0500 Subject: [PATCH] feat(recall): cross-session-continuity improvements --- chat.el | 123 ++++++++++++++++++++++++++++++++++++++++++++++++-- neuron-api.el | 18 ++++---- soul.el | 68 +++++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 16 deletions(-) diff --git a/chat.el b/chat.el index c101aa8..6fdc2dd 100644 --- a/chat.el +++ b/chat.el @@ -429,6 +429,28 @@ 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. + // 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") + } else { + let sum_nodes: String = engram_search_json("SessionSummary session:summary 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 +498,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 { "" } @@ -526,6 +552,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 { "[]" } @@ -968,7 +1002,9 @@ 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") { @@ -1041,6 +1077,24 @@ 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 get session-scoped labels, fixing ephemeral-only limitation (issue #4). + 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) + } + } + } true } else { false } @@ -1494,6 +1548,65 @@ 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. + 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) + } + } + 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") + 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 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") diff --git a/neuron-api.el b/neuron-api.el index f95d30e..fb95c4f 100644 --- a/neuron-api.el +++ b/neuron-api.el @@ -654,14 +654,12 @@ 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 - ) - } - return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}" + // 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 + "\"}" } diff --git a/soul.el b/soul.el index c58b03d..5033420 100644 --- a/soul.el +++ b/soul.el @@ -162,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. @@ -233,12 +275,36 @@ 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") + } 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 { "" } + } 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\"]" @@ -247,7 +313,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.