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, "") {