diff --git a/chat.el b/chat.el index e2d09d9..fe3369d 100644 --- a/chat.el +++ b/chat.el @@ -924,6 +924,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 { "[]" } @@ -1375,7 +1383,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") { @@ -1450,6 +1460,24 @@ fn handle_chat_agentic(body: String) -> String { // Increased from 20 to 40 turns: consistent with handle_chat window expansion. let trimmed: String = if json_array_len(updated2) > 40 { 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 } @@ -1903,6 +1931,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/soul.el b/soul.el index 56dfb13..db24573 100644 --- a/soul.el +++ b/soul.el @@ -310,12 +310,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\"]" @@ -324,7 +348,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.