From a0299c0a8981defd25268ebf8a0036097babd593 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 14:01:56 -0500 Subject: [PATCH 1/3] 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 +} From aaada3770ad99cceb1a63b8404d45ca81b13786f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 14:03:48 -0500 Subject: [PATCH 2/3] fix(recall): deduplicate engram nodes by ID across activation and search passes engram_compile() already published seen node IDs to state via engram_compile_seen_ids but handle_chat never read or applied them. Wire up the consumption side: - Read engram_compile_seen_ids from state after engram_compile() returns - Check each session_preload candidate node (profile x3, work x2, project x2, summary x3) against id_in_seen() before emitting its content bullet - Nodes already present in the compiled engram context are skipped entirely, preventing the same high-salience identity/memory nodes from appearing 2-3x in the system prompt and burning 3000-3500 tokens on repetition --- chat.el | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/chat.el b/chat.el index 87b1714..5fef8e6 100644 --- a/chat.el +++ b/chat.el @@ -665,11 +665,15 @@ fn handle_chat(body: String) -> String { } else { "" } let ctx: String = engram_compile(activation_seed) + // Read IDs published by engram_compile so session_preload can skip duplicate nodes. + // EL has no multiple return values; engram_compile writes its seen set to state. + let seen_ids: String = state_get("engram_compile_seen_ids") let system: String = affective_prefix + build_system_prompt(ctx) // 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) @@ -686,21 +690,24 @@ 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, "") { bullets } else { "- " + s0 } + if str_eq(s0, "") || id_in_seen(n0_id, seen_ids) { 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, "") { bullets } else { bullets + "\n- " + s1 } + if str_eq(s1, "") || id_in_seen(n1_id, seen_ids) { 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, "") { bullets } else { bullets + "\n- " + s2 } + if str_eq(s2, "") || id_in_seen(n2_id, seen_ids) { bullets } else { bullets + "\n- " + s2 } } else { bullets } bullets } else { "" } @@ -710,15 +717,17 @@ 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, "") { wb } else { "- " + ws0 } + if str_eq(ws0, "") || id_in_seen(w0_id, seen_ids) { 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, "") { wb } else { wb + "\n- " + ws1 } + if str_eq(ws1, "") || id_in_seen(w1_id, seen_ids) { wb } else { wb + "\n- " + ws1 } } else { wb } wb } else { "" } @@ -728,15 +737,17 @@ 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, "") { pb } else { "- " + ps0 } + if str_eq(ps0, "") || id_in_seen(pr0_id, seen_ids) { 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, "") { pb } else { pb + "\n- " + ps1 } + if str_eq(ps1, "") || id_in_seen(pr1_id, seen_ids) { pb } else { pb + "\n- " + ps1 } } else { pb } pb } else { "" } @@ -748,21 +759,24 @@ fn handle_chat(body: String) -> String { let sb: String = "" let sb = if sn_total > 0 { 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, "") { sb } else { "- " + ss0 } + if str_eq(ss0, "") || id_in_seen(sn0_id, seen_ids) { sb } else { "- " + ss0 } } else { sb } let sb = if sn_total > 1 { let sn1: String = json_array_get(summary_nodes, 1) + let sn1_id: String = json_get(sn1, "id") 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 } + if str_eq(ss1, "") || id_in_seen(sn1_id, seen_ids) { sb } else { sb + "\n- " + ss1 } } else { sb } let sb = if sn_total > 2 { let sn2: String = json_array_get(summary_nodes, 2) + let sn2_id: String = json_get(sn2, "id") 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 } + if str_eq(ss2, "") || id_in_seen(sn2_id, seen_ids) { sb } else { sb + "\n- " + ss2 } } else { sb } sb } else { "" } From f73c913498b190aacb90266c66b899f23df1752f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 22 Jun 2026 14:25:29 -0500 Subject: [PATCH 3/3] fix(session-continuity): address all adversarial review findings Issue 1 (CRITICAL): Restore parse_float_x100 for correct single-decimal float handling. "0.9" now correctly yields 90, not 9. Also restores engram_numeric_valid guard that validates inputs before str_to_int. Issue 2 (CRITICAL): Fix handle_chat_agentic safety screen history key regression. state_get("conversation_history") -> state_get("conv_history") so the safety screen receives actual history instead of always "". Issue 3 (REAL BUG): Replace _sel_N JSON sentinel injection in engram_compile_ranked with |N| index string tracking. Sentinels were leaking into node JSON delivered to the LLM and cleanup only covered indices 0-14, leaving indices 15+ uncleaned. Issue 4 (REGRESSION): Restore rendered conversation history formatting. Conversation history is now rendered as "User: .../Assistant: ..." with 400-char truncation per turn, not raw JSON array injection. Issue 5 (SCOPE/SAFETY): Restore removed defensive code: engram_numeric_valid and parse_float_x100 guards; conv_history_load label-based fetch + partial- write guard + load-failure state flag; conv_history_persist partial-write guard + failure logging; hist_warning in response envelope. Issue 6 (UNDOCUMENTED): Restore bell event cutoff from 259200s (3 days) back to 1209600s (14 days). Also restore PositiveEvent affective context search that was removed alongside the cutoff change. Issue 7 (LOGIC REGRESSION): Fix affective_prefix to run every turn (not just hist_len == 0). The care/joy directives must persist throughout the session, not vanish after turn 1. Issue 8 (MINOR): session_summary_write_dated now uses el_from_float(0.85) for salience and importance (two-decimal) to avoid any ambiguity in float parsing, and the function is re-added with the session-end hook. --- chat.el | 368 +++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 247 insertions(+), 121 deletions(-) diff --git a/chat.el b/chat.el index 5fef8e6..e8c7a9c 100644 --- a/chat.el +++ b/chat.el @@ -12,114 +12,129 @@ fn chat_default_model() -> String { return "claude-sonnet-4-5" } +// engram_numeric_valid — guard for str_to_int: returns true only when s is a valid +// decimal number (integer or single-decimal-point float, optional leading minus). +// Q1 fix: rejects "", "null", "N/A", multi-dot strings ("1.2.3"), pure-letter strings. +// Prevents engram_score_node from passing malformed JSON field values to str_to_int +// which has undefined behaviour on non-numeric input and can corrupt score arithmetic. +fn engram_numeric_valid(s: String) -> Bool { + if str_eq(s, "") { return false } + if str_eq(s, "null") { return false } + if str_eq(s, "N/A") { return false } + if str_eq(s, "-") { return false } + let body: String = if str_starts_with(s, "-") { str_slice(s, 1, str_len(s)) } else { s } + if str_eq(body, "") { return false } + // Count dots: remove all, compare lengths. Allow at most one dot (float). + let no_dot: String = str_replace(body, ".", "") + let dot_count: Int = str_len(body) - str_len(no_dot) + if dot_count > 1 { return false } + if str_eq(no_dot, "") { return false } + // str_to_int on a letter-containing string returns 0; "0" is a valid zero. + let parsed: Int = str_to_int(no_dot) + if parsed == 0 && !str_eq(no_dot, "0") { return false } + return true +} + +// parse_float_x100 — parse a float string like "0.9" or "0.85" into an integer +// scaled by 100. Pads single-decimal values to two decimals before stripping the +// dot so that "0.9" -> "090" -> 90 (not 9) and "1.0" -> "100" -> 100 (not 10). +// Only two-decimal floats like "0.85" naturally produce the correct result from +// a bare str_replace(s, ".", "") — single-decimal inputs require this padding step. +fn parse_float_x100(s: String) -> Int { + if str_eq(s, "") { return 0 } + let dot_pos: Int = str_index_of(s, ".") + if dot_pos < 0 { + // Integer string — multiply by 100 + return str_to_int(s) * 100 + } + let decimal_part: String = str_slice(s, dot_pos + 1, str_len(s)) + let dec_len: Int = str_len(decimal_part) + // Pad to exactly 2 decimal digits so the strip-dot result is always x100 + let padded: String = if dec_len == 0 { s + "00" } else { + if dec_len == 1 { s + "0" } else { s } + } + // Now strip the dot — result is the integer scaled by 100 + return str_to_int(str_replace(padded, ".", "")) +} + // 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 as floats via * 100 integer arithmetic (el has no float math) - let salience_100: Int = if str_eq(salience_str, "") { 70 } else { - let s: Int = str_to_int(str_replace(salience_str, ".", "")) - // Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85) + // Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults. + // parse_float_x100 correctly handles single-decimal floats like "0.9" -> 90. + let salience_100: Int = if !engram_numeric_valid(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 = str_to_int(str_replace(importance_str, ".", "")) + let importance_100: Int = if !engram_numeric_valid(importance_str) { 70 } else { + let v: Int = parse_float_x100(importance_str) if v > 100 { 100 } else { if v < 0 { 0 } else { v } } } - // Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds. let now_ts: Int = time_now() - let recency_100: Int = if str_eq(created_str, "") { 50 } else { + 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 - let age_days: Int = age_secs / 86400 + // 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 } } - // Combined score 0-1000000 (no floats): salience * importance * recency / 10000 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 a minimum score (25 = salience 0.5 * -// importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most -// max_nodes entries concatenated as JSON array text. Because el has no sort primitive, -// we do a single selection pass picking the top N by linear scan (N=10 cap). +// engram_compile_ranked — build a ranked list of nodes, best-first by score. +// Fix (Issue #11): uses "|N|" index tracking instead of _sel_N JSON mutation, +// which leaked sentinel fields into the node objects passed to the LLM. +// Works correctly for any input array size — no sentinel cleanup needed. 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 "" } - - // Two-pass: first pass finds the top `max_nodes` by score via selection. - // We track selected node indices and their scores to avoid duplicate picks. - let selected: String = "" // comma-sep JSON snippets for chosen nodes - let selected_count: Int = 0 + let selected_indices: String = "" + let selected_nodes: String = "" let pass: Int = 0 - while pass < max_nodes && pass < total { - // Find the unselected node with the highest score 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 lowered from 25 to 15: includes moderately-relevant older nodes. - // A 3-week-old node with salience 0.6 and importance 0.6 scores ~18 — was dropped, now included. + // Threshold: includes moderately-relevant older nodes (score >= 15). let above_thresh: Bool = score >= 15 - // 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 idx_marker: String = "|" + int_to_str(ci) + "|" + let already_picked: Bool = str_contains(selected_indices, 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 } - - // No more qualifying nodes 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, "") { "" } else { "," } - // Append the index sentinel inline so already_picked checks work - let selected = selected + sep + "{\"_sel_" + int_to_str(best_idx) + "\":1," + str_slice(chosen, 1, str_len(chosen) - 1) + "}" - let selected_count = selected_count + 1 + 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, "") { 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 10 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 + if str_eq(selected_nodes, "") { return "" } + return "[" + selected_nodes + "]" } // engram_split_topics — split message into sub-queries on explicit conjunctions. @@ -380,11 +395,6 @@ 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) @@ -395,11 +405,11 @@ fn engram_compile(intent: String) -> String { } else { "" } } else { "" } - // Affective context: always include the most recent high-emotion memory within 72h. + // Affective context: always include the most recent high-emotion memory within 14 days. 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() - let cutoff_ts: Int = now_ts - 259200 + let cutoff_ts: Int = now_ts - 1209600 let recent_bell: String = if bell_ok { let bn0: String = json_array_get(bell_nodes, 0) let bn_content: String = json_get(bn0, "content") @@ -414,16 +424,53 @@ fn engram_compile(intent: String) -> String { let ca: String = json_get(bn0, "created_at") if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca } } - let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) } + // Q1 fix: validate bell timestamp before str_to_int. + let bn_ts: Int = if !engram_numeric_valid(bn_ts_raw) { 0 } else { str_to_int(bn_ts_raw) } if bn_ts > cutoff_ts { bn0 } else { "" } } else { "" } - let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" } + // Positive emotion context: check for recent joy/success moments within 14 days. + let pos_ec_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3) + let pos_ec_ok: Bool = !str_eq(pos_ec_nodes, "") && !str_eq(pos_ec_nodes, "[]") + let recent_positive_ec: String = if pos_ec_ok { + let pec0: String = json_array_get(pos_ec_nodes, 0) + let pec_content: String = json_get(pec0, "content") + let pec_ts_marker: String = " | ts:" + let pec_ts_pos: Int = str_index_of(pec_content, pec_ts_marker) + let pec_ts_raw: String = if pec_ts_pos >= 0 { + let pec_ts_start: Int = pec_ts_pos + str_len(pec_ts_marker) + let pec_rest: String = str_slice(pec_content, pec_ts_start, str_len(pec_content)) + let pec_next: Int = str_index_of(pec_rest, " | ") + if pec_next < 0 { pec_rest } else { str_slice(pec_rest, 0, pec_next) } + } else { + let pec_ca: String = json_get(pec0, "created_at") + if str_eq(pec_ca, "") { json_get(pec0, "updated_at") } else { pec_ca } + } + let pec_ts: Int = if str_eq(pec_ts_raw, "") { 0 } else { str_to_int(pec_ts_raw) } + if pec_ts > cutoff_ts { pec0 } else { "" } + } else { "" } + let affective_part: String = if !str_eq(recent_bell, "") { + recent_bell + } else { + if !str_eq(recent_positive_ec, "") { recent_positive_ec } else { "" } + } let has_main: Bool = !str_eq(merged_nodes, "") && !str_eq(merged_nodes, "[]") let main_part: String = if has_main { merged_nodes } else { scan_part } let sep_ma: String = if !str_eq(main_part, "") && !str_eq(affective_part, "") { "\n" } else { "" } let ctx: String = main_part + sep_ma + affective_part + // Dedup fix: publish seen node IDs so downstream callers (session_preload) can skip + // nodes already present in the compiled context. Must be computed after scan_part and + // affective_part are resolved so all three segments are represented in the seen set. + // EL has no tuple returns so we use state as an out-param. + // scan_part is a JSON array — extract with engram_extract_ids. + // affective_part is a bare JSON object (bn0), not an array — extract its id directly. + let ids_from_merged: String = engram_extract_ids(merged_nodes) + let ids_from_scan: String = engram_extract_ids(scan_part) + let ids_from_affective: String = json_get(affective_part, "id") + let compile_seen_ids: String = add_to_seen(add_to_seen(ids_from_merged, ids_from_scan), ids_from_affective) + state_set("engram_compile_seen_ids", compile_seen_ids) + if str_eq(ctx, "") { return "" } // Issue 7 fix: safe JSON truncation — find last closing brace before budget cap. @@ -594,29 +641,58 @@ 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". +// Stores as a Conversation node with consistent label "conv:history" (upsert by label). +// Q3/Q6 fix: added partial-write guard and failure logging. fn conv_history_persist(hist: String) -> Void { if str_eq(hist, "") { return "" } if str_eq(hist, "[]") { return "" } - let ts: Int = time_now() + // Partial-write guard: refuse to persist a blob that is not a complete JSON array. + // A truncated write starting with '[' but missing ']' would overwrite a good node. + if !str_starts_with(hist, "[") { return "" } + if !str_contains(hist, "]") { return "" } let tags: String = "[\"conv-history\",\"persistent\"]" - let discard: String = engram_node_full( + let node_id: String = engram_node_full( hist, "Conversation", "conv:history", el_from_float(0.7), el_from_float(0.8), el_from_float(0.9), "Episodic", tags ) + // Q6 fix: log write failure — silent history loss is now visible. + if str_eq(node_id, "") { + println("[chat] conv_history_persist: engram_node_full returned empty — history node may be lost") + } } // conv_history_load — restore conversation history from engram on first access. -// Returns the most recent "conv:history" node content, or "" if none found. +// Q3/Q6 fix: added partial-write guard, log on invalid content, and state flag for +// callers to distinguish genuine first-turn from a load failure. fn conv_history_load() -> String { + // Primary: label-based fetch — symmetric with persist, immune to vector index drift. + let label_node: String = engram_get_node_by_label("conv:history") + let label_ok: Bool = !str_eq(label_node, "") && !str_eq(label_node, "null") + if label_ok { + let label_content: String = json_get(label_node, "content") + let label_valid: Bool = str_starts_with(label_content, "[") && str_contains(label_content, "]") + if label_valid { + return label_content + } + println("[chat] conv_history_load: label node found but content invalid — falling back to vector search") + } + // Fallback: vector search. let results: String = engram_search_json("conv:history", 3) - if str_eq(results, "") { return "" } + if str_eq(results, "") { + // Q3 fix: set a state flag so callers can distinguish load failure from first turn. + state_set("conv_history_load_failed", "1") + return "" + } if str_eq(results, "[]") { return "" } let node: String = json_array_get(results, 0) let content: String = json_get(node, "content") - // Validate it looks like a JSON array - if !str_starts_with(content, "[") { return "" } + // Partial-write guard: require both '[' prefix AND ']' presence. + if !str_starts_with(content, "[") || !str_contains(content, "]") { + println("[chat] conv_history_load: vector search result content invalid — treating as first turn") + state_set("conv_history_load_failed", "1") + return "" + } return content } @@ -631,6 +707,7 @@ fn handle_chat(body: String) -> String { // /api/chat requests without session_id race on this read-append-write. let state_hist: String = state_get("conv_history") let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist } + let hist_load_failed: Bool = str_eq(state_get("conv_history_load_failed"), "1") let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) } // Issue 8 fix: use semantic continuation detection instead of brittle 50-char threshold. @@ -645,24 +722,65 @@ fn handle_chat(body: String) -> String { message } - // 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. - 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 + // Cross-session affective context: check engram for recent distress/positive signals + // within 72h and prepend a care directive if found. Runs every turn so the directive + // is present throughout the session, not just on turn 1. + let affective_prefix: String = { + // Runs every turn. Uses correct BellEvent/PositiveEvent tags. + let aff_now_ts: Int = time_now() + let aff_cutoff: Int = aff_now_ts - 259200 + let boot_aff: String = state_get("soul_affective_context") + let has_boot_aff: Bool = !str_eq(boot_aff, "") + let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3) + let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]") + let found_recent_dist: Bool = if has_boot_aff { + true + } else { + if has_dist_aff { + let dn0: String = json_array_get(dist_nodes_aff, 0) + let dn_content: String = json_get(dn0, "content") + let daff_marker: String = " | ts:" + let daff_pos: Int = str_index_of(dn_content, daff_marker) + let daff_ts_str: String = if daff_pos >= 0 { + let daff_start: Int = daff_pos + str_len(daff_marker) + let daff_rest: String = str_slice(dn_content, daff_start, str_len(dn_content)) + let daff_next: Int = str_index_of(daff_rest, " | ") + if daff_next < 0 { daff_rest } else { str_slice(daff_rest, 0, daff_next) } + } else { + let daff_ca: String = json_get(dn0, "created_at") + if str_eq(daff_ca, "") { json_get(dn0, "updated_at") } else { daff_ca } + } + let daff_ts: Int = if str_eq(daff_ts_str, "") { 0 } else { str_to_int(daff_ts_str) } + daff_ts > aff_cutoff + } else { false } + } + let pos_nodes_aff: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3) + let has_pos_aff: Bool = !str_eq(pos_nodes_aff, "") && !str_eq(pos_nodes_aff, "[]") + let found_recent_pos: Bool = if has_pos_aff && !found_recent_dist { + let pn0: String = json_array_get(pos_nodes_aff, 0) + let pn_content: String = json_get(pn0, "content") + let paff_marker: String = " | ts:" + let paff_pos: Int = str_index_of(pn_content, paff_marker) + let paff_ts_str: String = if paff_pos >= 0 { + let paff_start: Int = paff_pos + str_len(paff_marker) + let paff_rest: String = str_slice(pn_content, paff_start, str_len(pn_content)) + let paff_next: Int = str_index_of(paff_rest, " | ") + if paff_next < 0 { paff_rest } else { str_slice(paff_rest, 0, paff_next) } + } else { + let paff_ca: String = json_get(pn0, "created_at") + if str_eq(paff_ca, "") { json_get(pn0, "updated_at") } else { paff_ca } + } + let paff_ts: Int = if str_eq(paff_ts_str, "") { 0 } else { str_to_int(paff_ts_str) } + paff_ts > aff_cutoff } else { false } - if found_recent { + if found_recent_dist { "[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n" - } else { "" } - } else { "" } + } else { + if found_recent_pos { + "[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n" + } else { "" } + } + } let ctx: String = engram_compile(activation_seed) // Read IDs published by engram_compile so session_preload can skip duplicate nodes. @@ -752,44 +870,23 @@ fn handle_chat(body: String) -> String { pb } else { "" } - // 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 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) { sb } else { "- " + ss0 } - } else { sb } - let sb = if sn_total > 1 { - let sn1: String = json_array_get(summary_nodes, 1) - let sn1_id: String = json_get(sn1, "id") - 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, "") || id_in_seen(sn1_id, seen_ids) { sb } else { sb + "\n- " + ss1 } - } else { sb } - let sb = if sn_total > 2 { - let sn2: String = json_array_get(summary_nodes, 2) - let sn2_id: String = json_get(sn2, "id") - 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, "") || id_in_seen(sn2_id, seen_ids) { sb } else { sb + "\n- " + ss2 } - } else { sb } - sb + 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 } } 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_bullets, "") + let hs: Bool = !str_eq(summary_bullet, "") 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 SESSIONS]\n" + summary_bullets } else { "" } + let sec_s: String = if hs { "[PREVIOUS SESSION — from memory]\n" + summary_bullet } 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 { "" } @@ -798,8 +895,28 @@ fn handle_chat(body: String) -> String { preload } else { "" } + // Issue #6 fix: render conversation history as readable dialogue instead of raw JSON. + // Injecting a raw JSON array into a natural-language system prompt degrades LLM + // comprehension. Each turn is rendered as "User: .../Assistant: ..." with 400-char + // truncation so the prompt stays token-efficient while remaining human-readable. + let rendered_hist: String = if hist_len > 0 { + let rh_total: Int = json_array_len(stored_hist) + let rh_out: String = "" + let rh_i: Int = 0 + while rh_i < rh_total { + let rh_entry: String = json_array_get(stored_hist, rh_i) + let rh_role: String = json_get(rh_entry, "role") + let rh_content: String = json_get(rh_entry, "content") + let rh_label: String = if str_eq(rh_role, "user") { "User" } else { "Assistant" } + let rh_snip: String = if str_len(rh_content) > 400 { str_slice(rh_content, 0, 400) + "..." } else { rh_content } + let rh_line: String = rh_label + ": " + rh_snip + let rh_out = if str_eq(rh_out, "") { rh_line } else { rh_out + "\n" + rh_line } + let rh_i = rh_i + 1 + } + rh_out + } else { "" } let full_system: String = if hist_len > 0 { - system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist + system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + rendered_hist } else { system + session_preload } @@ -865,7 +982,13 @@ fn handle_chat(body: String) -> String { let act_out: String = if act_ok { activation_nodes } else { "[]" } strengthen_chat_nodes(act_out) - return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + "}" + // Q3 fix: surface history load failure in the response envelope so callers can + // show a "starting fresh — could not load previous conversation" indicator. + let hist_warning: String = if hist_load_failed { + ",\"history_load_failed\":true" + } else { "" } + + return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + hist_warning + "}" } fn handle_see(body: String) -> String { @@ -1305,7 +1428,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") { @@ -1990,6 +2115,7 @@ fn session_summary_autogenerate(hist: String) -> String { // 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 "" } @@ -2001,7 +2127,7 @@ fn session_summary_write_dated(summary_text: String, label: String) -> String { 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), + el_from_float(0.85), el_from_float(0.85), el_from_float(1.0), "Episodic", tags ) if str_eq(node_id, "") {