From a0299c0a8981defd25268ebf8a0036097babd593 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 14:01:56 -0500 Subject: [PATCH] fix(recall): session-end summary hook + session summary recall at start --- chat.el | 148 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 7 deletions(-) diff --git a/chat.el b/chat.el index 0c65b87..87b1714 100644 --- a/chat.el +++ b/chat.el @@ -265,6 +265,39 @@ fn engram_nodes_merge(a: String, b: String) -> String { return engram_dedup_nodes("[" + ai + "," + bi + "]") } +// id_in_seen — check if node_id appears in the comma-delimited seen accumulator. +// Pads both sides with commas to avoid false substring matches. +fn id_in_seen(node_id: String, seen: String) -> Bool { + if str_eq(node_id, "") { return false } + if str_eq(seen, "") { return false } + return str_contains("," + seen + ",", "," + node_id + ",") +} + +// add_to_seen — append node_id to the comma-delimited seen accumulator. +fn add_to_seen(seen: String, node_id: String) -> String { + if str_eq(node_id, "") { return seen } + if str_eq(seen, "") { return node_id } + return seen + "," + node_id +} + +// engram_extract_ids — extract all non-empty "id" fields from a JSON node array +// into a comma-delimited string for use with id_in_seen / add_to_seen. +fn engram_extract_ids(nodes_json: String) -> String { + if str_eq(nodes_json, "") { return "" } + if str_eq(nodes_json, "[]") { return "" } + let total: Int = json_array_len(nodes_json) + if total == 0 { return "" } + let ids: String = "" + let i: Int = 0 + while i < total { + let node: String = json_array_get(nodes_json, i) + let nid: String = json_get(node, "id") + let ids = if str_eq(nid, "") { ids } else { add_to_seen(ids, nid) } + let i = i + 1 + } + return ids +} + fn engram_compile(intent: String) -> String { // Issue 1: decompose multi-topic messages into sub-queries. let topics: String = engram_split_topics(intent) @@ -347,6 +380,11 @@ fn engram_compile(intent: String) -> String { let merged: String = engram_nodes_merge(merged, recall_boost) let merged_nodes: String = merged + // Dedup fix: publish seen node IDs so downstream callers (session_preload, affective_prefix) + // can skip nodes already present here. EL has no tuple returns so we use state as out-param. + let compile_seen_ids: String = engram_extract_ids(merged_nodes) + state_set("engram_compile_seen_ids", compile_seen_ids) + // Fallback: when all searches return nothing, fetch persona nodes. let scan_part: String = if str_eq(merged_nodes, "") || str_eq(merged_nodes, "[]") { let persona_fallback: String = engram_search_json("soul:persona Persona identity", 5) @@ -703,22 +741,41 @@ fn handle_chat(body: String) -> String { pb } else { "" } - let summary_bullet: String = if summary_ok { - let sn0: String = json_array_get(summary_nodes, 0) - let sc0: String = json_get(sn0, "content") - let ss0: String = if str_len(sc0) > 200 { str_slice(sc0, 0, 200) } else { sc0 } - if str_eq(ss0, "") { "" } else { "- " + ss0 } + // Session summary recall: show up to 3 previous session summaries so the soul + // knows what was discussed in recent past conversations. + let summary_bullets: String = if summary_ok { + let sn_total: Int = json_array_len(summary_nodes) + let sb: String = "" + let sb = if sn_total > 0 { + let sn0: String = json_array_get(summary_nodes, 0) + let sc0: String = json_get(sn0, "content") + let ss0: String = if str_len(sc0) > 200 { str_slice(sc0, 0, 200) } else { sc0 } + if str_eq(ss0, "") { sb } else { "- " + ss0 } + } else { sb } + let sb = if sn_total > 1 { + let sn1: String = json_array_get(summary_nodes, 1) + let sc1: String = json_get(sn1, "content") + let ss1: String = if str_len(sc1) > 200 { str_slice(sc1, 0, 200) } else { sc1 } + if str_eq(ss1, "") { sb } else { sb + "\n- " + ss1 } + } else { sb } + let sb = if sn_total > 2 { + let sn2: String = json_array_get(summary_nodes, 2) + let sc2: String = json_get(sn2, "content") + let ss2: String = if str_len(sc2) > 200 { str_slice(sc2, 0, 200) } else { sc2 } + if str_eq(ss2, "") { sb } else { sb + "\n- " + ss2 } + } else { sb } + sb } else { "" } let hp: Bool = !str_eq(profile_bullets, "") let hw: Bool = !str_eq(work_bullets, "") let hpr: Bool = !str_eq(project_bullets, "") - let hs: Bool = !str_eq(summary_bullet, "") + let hs: Bool = !str_eq(summary_bullets, "") let preload: String = if hp || hw || hpr || hs { let sec_p: String = if hp { "[USER CONTEXT — from memory]\n" + profile_bullets } else { "" } let sec_w: String = if hw { "[ACTIVE WORK — from memory]\n" + work_bullets } else { "" } let sec_pr: String = if hpr { "[PROJECTS — from memory]\n" + project_bullets } else { "" } - let sec_s: String = if hs { "[PREVIOUS SESSION — from memory]\n" + summary_bullet } else { "" } + let sec_s: String = if hs { "[PREVIOUS SESSIONS]\n" + summary_bullets } else { "" } let sep1: String = if hp && (hw || hpr || hs) { "\n\n" } else { "" } let sep2: String = if hw && (hpr || hs) { "\n\n" } else { "" } let sep3: String = if hpr && hs { "\n\n" } else { "" } @@ -764,6 +821,31 @@ fn handle_chat(body: String) -> String { state_set("conv_history", final_hist) conv_history_persist(final_hist) + // Session-end summary hook: write a dated SessionSummary node once per boot when + // the conversation reaches >= 5 user turns (10 hist entries = 5 user+assistant pairs). + // Uses a per-boot label ("session:summary:") so summaries accumulate across + // sessions instead of overwriting a single global node. A state flag prevents rewriting + // on every subsequent turn once the threshold is crossed. + let final_hist_len: Int = json_array_len(final_hist) + if final_hist_len >= 10 { + let already_wrote: String = state_get("session_summary_written") + if str_eq(already_wrote, "") { + // Derive (or create) a stable boot-scoped session id. + let boot_id: String = state_get("session_boot_id") + let boot_id = if str_eq(boot_id, "") { + let new_id: String = int_to_str(time_now()) + state_set("session_boot_id", new_id) + new_id + } else { boot_id } + let sess_label: String = "session:summary:" + boot_id + let auto_sum: String = session_summary_autogenerate(final_hist) + if !str_eq(auto_sum, "") { + let discard_sum: String = session_summary_write_dated(auto_sum, sess_label) + state_set("session_summary_written", "1") + } + } + } + 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 { "[]" } @@ -1863,3 +1945,55 @@ fn strengthen_chat_nodes(activation_nodes: String) -> Void { let i = i + 1 } } + +// session_summary_autogenerate — build a minimal summary from conversation history without LLM. +// Extracts user message snippets (first 80 chars each, up to 5 turns). +// Called by the session-end hook when >= 5 complete turns have occurred. +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 +} + +// session_summary_write_dated — write a SessionSummary node with a caller-supplied dated label. +// Unlike a global-label write, this does NOT delete old nodes — each session accumulates its +// own node so engram_search_json("session:summary") can return multiple past sessions. +// The label must be unique per session (e.g. "session:summary:"). +fn session_summary_write_dated(summary_text: String, label: String) -> String { + if str_eq(summary_text, "") { return "" } + if str_eq(label, "") { 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 + let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]" + let node_id: String = engram_node_full( + content, "SessionSummary", label, + el_from_float(0.9), el_from_float(0.8), el_from_float(1.0), + "Episodic", tags + ) + if str_eq(node_id, "") { + println("[chat] session_summary_write_dated: engram write failed — summary node lost (label=" + label + ")") + return "" + } + println("[chat] session_summary_write_dated: wrote SessionSummary (" + int_to_str(str_len(content)) + " chars) label=" + label + " -> " + node_id) + return node_id +}