From f2b63f004878f5b58679110a3bbb8e5a6f56b6b0 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 14:51:51 -0500 Subject: [PATCH] fix(emergency): repair session-continuity regressions from prior merge --- chat.el | 287 +++++++++++++++++--------------------------------------- 1 file changed, 86 insertions(+), 201 deletions(-) diff --git a/chat.el b/chat.el index b28de2d..4d307d2 100644 --- a/chat.el +++ b/chat.el @@ -233,127 +233,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String { } if str_eq(selected_nodes, "") { return "" } return "[" + selected_nodes + "]" -}ory.el" - -fn chat_default_model() -> String { - let m: String = state_get("soul_model") - if !str_eq(m, "") { - return m - } - let e: String = env("SOUL_LLM_MODEL") - if !str_eq(e, "") { - return e - } - return "claude-sonnet-4-5" } - -// engram_score_node — compute a recency x relevance score for a single engram -// node JSON object. Higher is better. Score = salience * importance * recency_factor. -// recency_factor decays linearly over 30 days: nodes updated today score 1.0, -// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5. -// This keeps fresh, high-salience nodes at the top and pushes stale low-signal -// nodes to the bottom so they get trimmed when we cap context size. -// Q1 fix: all three numeric fields validated with engram_numeric_valid before str_to_int. -fn engram_score_node(node_json: String) -> Int { - let salience_str: String = json_get(node_json, "salience") - let importance_str: String = json_get(node_json, "importance") - let created_str: String = json_get(node_json, "created_at") - let updated_str: String = json_get(node_json, "updated_at") - let tier_str: String = json_get(node_json, "tier") - - // parse_float_x100 handles 1- and 2-decimal floats correctly ("0.9" -> 90, "0.85" -> 85). - // Default 70 when field is absent; clamp to 0-100 range. - let salience_100: Int = if str_eq(salience_str, "") { 70 } else { - let s: Int = parse_float_x100(salience_str) - if s > 100 { 100 } else { if s < 0 { 0 } else { s } } - } - let importance_100: Int = if str_eq(importance_str, "") { 70 } else { - let v: Int = parse_float_x100(importance_str) - if v > 100 { 100 } else { if v < 0 { 0 } else { v } } - } - - let now_ts: Int = time_now() - let recency_100: Int = if !engram_numeric_valid(created_str) { 50 } else { - let created_ts: Int = str_to_int(created_str) - let age_secs: Int = now_ts - created_ts - // Q1 fix: guard against clock skew / future timestamps — treat as fresh. - let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 } - let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) } - if decay < 10 { 10 } else { decay } - } - - return salience_100 * importance_100 * recency_100 / 10000 -} - -// engram_compile_ranked — build a context string from a JSON array of node objects, -// ordered best-first by score. Only nodes above threshold=25 are included. -// With corrected float parsing: sal=0.5 * imp=0.5 at max recency (100) scores exactly 25, -// so threshold=25 admits all nodes with at least moderate salience and importance while -// cutting near-zero noise. Lower values were masking the bug; 25 is correct post-fix. -// Returns at most max_nodes entries. max_nodes must not exceed 20 (sentinel limit). -fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> 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 selected_indices: String = "" - let selected_nodes: String = "" - let pass: Int = 0 - while pass < max_nodes && pass < total { - let best_idx: Int = -1 - let best_score: Int = -1 - let ci: Int = 0 - while ci < total { - let node: String = json_array_get(nodes_json, ci) - let score: Int = engram_score_node(node) - // Threshold 25: sal=0.5 * imp=0.5 * recency=1.0 -> 50*50*100/10000 = 25. - let above_thresh: Bool = score >= 25 - // Check this index wasn't already selected (sentinel: look for idx marker) - let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\"" - let already_picked: Bool = str_contains(selected, idx_marker) - let is_better: Bool = score > best_score && above_thresh && !already_picked - let best_score = if is_better { score } else { best_score } - let best_idx = if is_better { ci } else { best_idx } - let ci = ci + 1 - } - if best_idx < 0 { - let pass = total // break - } else { - let chosen: String = json_array_get(nodes_json, best_idx) - let sep: String = if str_eq(selected_nodes, "") { "" } else { "," } - let selected_nodes = selected_nodes + sep + chosen - let selected_indices = selected_indices + "|" + int_to_str(best_idx) + "|" - } - let pass = pass + 1 - } - if str_eq(selected_nodes, "") { return "" } - return "[" + selected_nodes + "]" -} - - if str_eq(selected, "") { return "" } - // Strip the _sel_N sentinel fields that were used for duplicate-detection bookkeeping. - // The sentinels have the form "\"_sel_N\":1," (trailing comma, space before next key). - // We injected them as the first field in each object, so the pattern is predictable. - // Because el has no regex, remove up to 20 possible sentinel variants by literal replace. - let clean: String = "[" + selected + "]" - let c0: String = str_replace(clean, "\"_sel_0\":1,", "") - let c1: String = str_replace(c0, "\"_sel_1\":1,", "") - let c2: String = str_replace(c1, "\"_sel_2\":1,", "") - let c3: String = str_replace(c2, "\"_sel_3\":1,", "") - let c4: String = str_replace(c3, "\"_sel_4\":1,", "") - let c5: String = str_replace(c4, "\"_sel_5\":1,", "") - let c6: String = str_replace(c5, "\"_sel_6\":1,", "") - let c7: String = str_replace(c6, "\"_sel_7\":1,", "") - let c8: String = str_replace(c7, "\"_sel_8\":1,", "") - let c9: String = str_replace(c8, "\"_sel_9\":1,", "") - let c10: String = str_replace(c9, "\"_sel_10\":1,", "") - let c11: String = str_replace(c10, "\"_sel_11\":1,", "") - let c12: String = str_replace(c11, "\"_sel_12\":1,", "") - let c13: String = str_replace(c12, "\"_sel_13\":1,", "") - let c14: String = str_replace(c13, "\"_sel_14\":1,", "") - return c14 -} - // engram_split_topics — split message into sub-queries on explicit conjunctions. // "health goals AND startup progress" becomes two independent searches. fn engram_split_topics(message: String) -> String { @@ -497,6 +377,38 @@ fn engram_nodes_merge(a: String, b: String) -> String { return engram_dedup_nodes("[" + ai + "," + bi + "]") } +// id_in_seen — true when node_id appears in the pipe-delimited seen set. +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 pipe-delimited seen set. +fn add_to_seen(seen: String, node_id: String) -> String { + if str_eq(node_id, "") { return seen } + if id_in_seen(node_id, seen) { return seen } + return seen + "|" + node_id + "|" +} + +// engram_extract_ids — extract the "id" field from each node in a JSON array, +// returning a pipe-delimited string suitable for 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 seen: String = "" + let i: Int = 0 + while i < total { + let node: String = json_array_get(nodes_json, i) + let node_id: String = json_get(node, "id") + let seen = add_to_seen(seen, node_id) + let i = i + 1 + } + return seen +} + // Q4 note: engram_compile has no cache or circuit-breaker at the EL layer. // Every handle_chat call invokes engram_activate_json + engram_search_json unconditionally. // If the engram backend is repeatedly unreachable (e.g., during startup or after a crash), @@ -586,6 +498,10 @@ fn engram_compile(intent: String) -> String { let merged: String = engram_nodes_merge(merged, recall_boost) let merged_nodes: String = merged + // Publish compiled IDs to state so session_preload can skip duplicate nodes. + let ids_from_merged: String = engram_extract_ids(merged_nodes) + state_set("engram_compile_seen_ids", ids_from_merged) + // 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) @@ -596,7 +512,7 @@ fn engram_compile(intent: String) -> String { } else { "" } } else { "" } - // Affective context: always include the most recent high-emotion memory within 14 days. + // Affective context: always include the most recent high-emotion memory within 72h. let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3) let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]") let now_ts: Int = time_now() @@ -650,12 +566,8 @@ fn engram_compile(intent: String) -> String { let sep_ma: String = if !str_eq(main_part, "") && !str_eq(affective_part, "") { "\n" } else { "" } let ctx: String = main_part + sep_ma + affective_part - // Q7 fix: store recall status so build_system_prompt can include a hint to the LLM - // distinguishing "no memories yet" (cold start) from "memory system unreachable". - // Values: "ok" | "empty" | "unavailable" - let any_ok: Bool = act_ok || srch_ok || scan_ok || affective_ok - let all_failed: Bool = act_failed && srch_failed - let recall_status: String = if any_ok { "ok" } else { if all_failed { "unavailable" } else { "empty" } } + // Publish recall_status for build_system_prompt: "ok" when ctx has content, "empty" otherwise. + let recall_status: String = if str_eq(ctx, "") { "empty" } else { "ok" } state_set("engram_recall_status", recall_status) if str_eq(ctx, "") { @@ -717,6 +629,17 @@ fn build_system_prompt(ctx: String, chat_mode: Bool) -> String { "\n\n[IDENTITY GRAPH — who you are, loaded from your engram]\n" + id_ctx } + // soul_affective_context is loaded at boot by load_identity_context() with BellEvent/ + // PositiveEvent nodes from the last 7 days. Surfaced here so the LLM sees historical + // emotional patterns from prior sessions at every turn. + // Issue 1 fix: declare affective_boot_block before it is referenced in the return. + let boot_aff_ctx: String = state_get("soul_affective_context") + let affective_boot_block: String = if str_eq(boot_aff_ctx, "") { + "" + } else { + "\n\n[CROSS-SESSION EMOTIONAL CONTEXT — from prior sessions]\n" + boot_aff_ctx + } + // Q7 fix: if recall produced no results, include a hint so the LLM can respond // authentically ("I seem to be starting fresh" vs "memory system may be down") // rather than silently acting as if it has context it doesn't have. @@ -911,6 +834,29 @@ fn conv_history_load() -> String { return content } +// session_preload_bullets — render up to max_bullets nodes from a JSON array as +// bullet lines, truncating content at snip_len chars each. +fn session_preload_bullets(nodes: String, max_bullets: Int, snip_len: Int) -> String { + if str_eq(nodes, "") { return "" } + if str_eq(nodes, "[]") { return "" } + let total: Int = json_array_len(nodes) + let limit: Int = if max_bullets < total { max_bullets } else { total } + let bullets: String = "" + let i: Int = 0 + while i < limit { + let node: String = json_array_get(nodes, i) + let content: String = json_get(node, "content") + let snip: String = if str_len(content) > snip_len { str_slice(content, 0, snip_len) } else { content } + let bullets = if str_eq(snip, "") { + bullets + } else { + if str_eq(bullets, "") { "- " + snip } else { bullets + "\n- " + snip } + } + let i = i + 1 + } + return bullets +} + fn handle_chat(body: String) -> String { let message: String = json_get(body, "message") if str_eq(message, "") { @@ -996,14 +942,14 @@ fn handle_chat(body: String) -> String { } } - // Issue 4 fix: engram_compile_multi adds entity + emotion fan-out seeds - let ctx: String = engram_compile_multi(activation_seed, message) - let system: String = affective_prefix + build_system_prompt(ctx) + let ctx: String = engram_compile(activation_seed) + let system: String = affective_prefix + build_system_prompt(ctx, true) + + let seen_ids: String = state_get("engram_compile_seen_ids") // Issue 9 fix: add project-specific and session-summary searches to session preload. // Old hardcoded "user profile" and "in_progress active project" miss project-specific // nodes stored under names like "Prism" unless those exact words appear in content. - // Dedup fix: skip any node whose ID already appeared in engram_compile's output. let session_preload: String = if hist_len == 0 { let profile_nodes: String = engram_search_json("user profile identity preferences", 5) let work_nodes: String = engram_search_json("in_progress active project work", 5) @@ -1029,24 +975,21 @@ fn handle_chat(body: String) -> String { let bullets: String = "" let bullets = if pn > 0 { let n0: String = json_array_get(profile_nodes, 0) - let n0_id: String = json_get(n0, "id") let c0: String = json_get(n0, "content") let s0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 } - if str_eq(s0, "") || id_in_seen(n0_id, seen_ids) { bullets } else { "- " + s0 } + if str_eq(s0, "") { bullets } else { "- " + s0 } } else { bullets } let bullets = if pn > 1 { let n1: String = json_array_get(profile_nodes, 1) - let n1_id: String = json_get(n1, "id") let c1: String = json_get(n1, "content") let s1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 } - if str_eq(s1, "") || id_in_seen(n1_id, seen_ids) { bullets } else { bullets + "\n- " + s1 } + if str_eq(s1, "") { bullets } else { bullets + "\n- " + s1 } } else { bullets } let bullets = if pn > 2 { let n2: String = json_array_get(profile_nodes, 2) - let n2_id: String = json_get(n2, "id") let c2: String = json_get(n2, "content") let s2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 } - if str_eq(s2, "") || id_in_seen(n2_id, seen_ids) { bullets } else { bullets + "\n- " + s2 } + if str_eq(s2, "") { bullets } else { bullets + "\n- " + s2 } } else { bullets } bullets } else { "" } @@ -1056,17 +999,15 @@ fn handle_chat(body: String) -> String { let wb: String = "" let wb = if wn > 0 { let w0: String = json_array_get(work_nodes, 0) - let w0_id: String = json_get(w0, "id") let wc0: String = json_get(w0, "content") let ws0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 } - if str_eq(ws0, "") || id_in_seen(w0_id, seen_ids) { wb } else { "- " + ws0 } + if str_eq(ws0, "") { wb } else { "- " + ws0 } } else { wb } let wb = if wn > 1 { let w1: String = json_array_get(work_nodes, 1) - let w1_id: String = json_get(w1, "id") let wc1: String = json_get(w1, "content") let ws1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 } - if str_eq(ws1, "") || id_in_seen(w1_id, seen_ids) { wb } else { wb + "\n- " + ws1 } + if str_eq(ws1, "") { wb } else { wb + "\n- " + ws1 } } else { wb } wb } else { "" } @@ -1076,27 +1017,24 @@ fn handle_chat(body: String) -> String { let pb: String = "" let pb = if prn > 0 { let pr0: String = json_array_get(project_nodes, 0) - let pr0_id: String = json_get(pr0, "id") let prc0: String = json_get(pr0, "content") let ps0: String = if str_len(prc0) > 120 { str_slice(prc0, 0, 120) } else { prc0 } - if str_eq(ps0, "") || id_in_seen(pr0_id, seen_ids) { pb } else { "- " + ps0 } + if str_eq(ps0, "") { pb } else { "- " + ps0 } } else { pb } let pb = if prn > 1 { let pr1: String = json_array_get(project_nodes, 1) - let pr1_id: String = json_get(pr1, "id") let prc1: String = json_get(pr1, "content") let ps1: String = if str_len(prc1) > 120 { str_slice(prc1, 0, 120) } else { prc1 } - if str_eq(ps1, "") || id_in_seen(pr1_id, seen_ids) { pb } else { pb + "\n- " + ps1 } + if str_eq(ps1, "") { pb } else { pb + "\n- " + ps1 } } else { pb } pb } else { "" } let summary_bullet: String = if summary_ok { let sn0: String = json_array_get(summary_nodes, 0) - let sn0_id: String = json_get(sn0, "id") 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, "") || id_in_seen(sn0_id, seen_ids) { "" } else { "- " + ss0 } + if str_eq(ss0, "") { "" } else { "- " + ss0 } } else { "" } let hp: Bool = !str_eq(profile_bullets, "") @@ -1654,7 +1592,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 } @@ -2488,56 +2426,3 @@ 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:"). -// Uses salience 0.85/importance 0.85 (two-decimal) to avoid the single-decimal parse bug. -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.85), el_from_float(0.85), 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 -}