@@ -12,113 +12,47 @@ fn chat_default_model() -> String {
return " claude-sonnet-4-5 "
}
// parse_salience_100 — convert a %g-serialized float to integer * 100.
// The C runtime serializes floats with %g which trims trailing zeros:
// 0.70 → " 0.7 " , 0.60 → " 0.6 " , 0.50 → " 0.5 " , 1.0 → " 1 "
// The naive str_replace ( " . " , " " ) approach breaks for single-decimal strings:
// " 0.7 " → " 07 " → str_to_int → 7 ( WRONG, should be 70 )
// " 0.5 " → " 05 " → str_to_int → 5 ( WRONG, should be 50 )
// " 0.85 " → " 085 " → str_to_int → 85 ( accidentally correct — two decimal digits )
// Fix: use str_index_of to find the decimal point and scale accordingly:
// No decimal ( " 1 " ) : multiply raw by 100
// One decimal digit ( " 0.7 " ) : multiply stripped value by 10
// Two+ decimal digits ( " 0.85 " ) : stripped value is already in hundredths
fn parse_salience_100 ( s: String ) -> Int {
if str_eq ( s, " " ) { return 70 }
let dot_pos: Int = str_index_of ( s, " . " )
let raw: Int = if dot_pos < 0 {
// No decimal point — integer like " 1 " means 100%
str_to_int ( s ) * 100
} else {
let after_dot: String = str_slice ( s, dot_pos + 1 , str_len ( s ) )
let decimal_digits: Int = str_len ( after_dot )
let stripped: Int = str_to_int ( str_replace ( s, " . " , " " ) )
if decimal_digits == 1 { stripped * 10 } else { stripped }
}
if raw > 100 { 100 } else { if raw < 0 { 0 } else { raw } }
}
// engram_score_node — compute a recency x relevance score for a single engram
// node JSON object. Higher is better.
//
// Bugs fixed vs original implementation:
// 1. FLOAT PARSING: parse_salience_100 correctly handles %g single-decimal output.
// " 0.7 " → 70 , " 0.6 " → 60 , " 0.5 " → 50 ( was: 7 , 6 , 5 — scored near zero and
// were filtered by threshold=25, making the function broken for the majority
// of the graph where conv/utterance nodes have salience/importance ≈ 0.6/0.7 ) .
// 2. RECENCY USES LAST TOUCH: uses max ( created_at, updated_at, last_activated ) so
// nodes strengthened by engram_strengthen ( ) after chat turns are not penalised
// for a stale created_at. A node referenced yesterday but created 25 days ago
// now correctly scores as fresh rather than borderline-filtered.
// 3. COMPRESSED RECENCY RANGE: old formula ( sal * imp * recency / 10000 ) gave
// recency a 10x dynamic range ( 10-100 ) vs 1.9x for salience/importance. A
// canonical high-importance node at 30 days scored the same as a fresh noise
// node. New formula compresses recency to 1.54x via ( 50 + recency/2 ) weight.
// 4. SOFTER FLOOR: recency floor raised from 10 to 30 with tier-aware decay windows
// so canonical identity/persona nodes never bottom out to near-zero.
// 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.
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 activated_str: String = json_get ( node_json, " last_activated " )
let tier_str: String = json_get ( node_json, " tier " )
// parse_salience_100 handles " 0.7 " → 70 , " 0.85 " → 85 , " 1.0 " → 100 , " 1 " → 100
let salience_100: Int = parse_salience_100 ( salience_str )
let importance_100: Int = parse_salience_100 ( importance_str )
// Recency: use max ( created_at, updated_at, last_activated ) .
// last_activated is updated by engram_strengthen ( ) every chat turn — nodes
// actively referenced score fresh regardless of original write time.
let now_ts : Int = time_now ( )
let created_ts: Int = if str_eq ( created_str, " " ) { 0 } else { str_to_int ( created_str ) }
let updated_ts: Int = if str_eq ( updated_str, " " ) { 0 } else { str_to_int ( updated_str ) }
let activated_ts: Int = if str_eq ( activated_str, " " ) { 0 } else { str_to_int ( activated_str ) }
let best_ts_ab: Int = if updated_ts > created_ts { updated_ts } else { created_ts }
let best_ts: Int = if activated_ts > best_ts_ab { activated_ts } else { best_ts_ab }
let recency_100: Int = if best_ts == 0 { 50 } else {
let age_secs: Int = now_ts - best_ts
// Guard against clock skew ( future timestamps ) : treat as brand new.
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
// Tier-aware decay, softer floor ( 30 not 10 ) :
// Canonical: 365-day window — foundational identity/persona nodes.
// Episodic: 90-day window — conversation context fades moderately.
// Working/untiered: 35-day window — transient task state.
let is_canonical: Bool = str_eq ( tier_str, " Canonical " )
let is_episodic: Bool = str_eq ( tier_str, " Episodic " )
let decay: Int = if is_canonical {
let drop: Int = if age_days >= 365 { 70 } else { age_days * 70 / 365 }
100 - drop
} else {
if is_episodic {
if age_days >= 90 { 30 } else { 100 - ( age_days * 70 / 90 ) }
} else {
if age_days >= 35 { 30 } else { 100 - ( age_days * 2 ) }
}
}
if decay < 30 { 30 } else { decay }
// 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 )
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, " . " , " " ) )
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
}
// Compressed recency weight ( 50 + recency/2 ) : range 65-100 ( 1.54x dynamic range ) .
// Old formula had 10x recency range which drowned out relevance for old-but-important
// nodes. New: relevance ( 0-100 ) × recency_weight ( 65-100 ) / 100 → score 0-100.
// salience_100 and importance_100 are already in the 0-100 range ( parse_salience_100
// returns e.g. 70 for " 0.7 " ) . Dividing by 100 keeps relevance in 0-100.
// Dividing by 10000 caused integer truncation to 0 for all real-world nodes
// ( e.g., sal=0.7, imp=0.7 → 70*70/10000 = 0 instead of 49 ) .
let relevance: Int = salience_100 * importance_100 / 100
let recency_weight: Int = 50 + recency_100 / 2
return relevance * recency_weight / 100
// 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 created_ts: Int = str_to_int ( created_str )
let age_secs: Int = now_ts - created_ts
let age_days: Int = 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 threshold=10 are included.
// With corrected formula ( sal*imp/100 ) : sal=0.5*imp=0.5 at max recency scores 25 ;
// sal=0.5*imp=0.5 at Working floor ( recency=30, weight=65 ) scores 16.
// Threshold=10 gives safe headroom for low-salience nodes near the recency floor,
// while still filtering near-zero noise ( e.g., sal=0.1*imp=0.1 → score ≤ 1 ) .
// Returns at most max_nodes entries. max_nodes must not exceed 20 ( sentinel limit ) .
// 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 ) .
fn engram_compile_ranked ( nodes_json: String, max_nodes: Int ) -> String {
if str_eq ( nodes_json, " " ) { return " " }
if str_eq ( nodes_json, " [] " ) { return " " }
@@ -139,10 +73,8 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
while ci < total {
let node: String = json_array_get ( nodes_json, ci )
let score: Int = engram_score_node ( node )
// Threshold=10: allows moderately-relevant older nodes while filtering noise.
// Example: sal=0.5 imp=0.5 at Working recency floor ( 35+ days ) → score 16 ,
// which passes. A near-zero node ( sal=0.1 imp=0.1 ) → score ≤ 1 , filtered.
let above_thresh: Bool = score >= 10
// Only include reasonably relevant nodes ( threshold=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 )
@@ -169,7 +101,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
// 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.
// 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, " , " " )
@@ -181,17 +113,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
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, " , " " )
let c15: String = str_replace ( c14, " \" _sel_15 \" :1, " , " " )
let c16: String = str_replace ( c15, " \" _sel_16 \" :1, " , " " )
let c17: String = str_replace ( c16, " \" _sel_17 \" :1, " , " " )
let c18: String = str_replace ( c17, " \" _sel_18 \" :1, " , " " )
let c19: String = str_replace ( c18, " \" _sel_19 \" :1, " , " " )
return c19
return c9
}
fn engram_compile ( intent: String ) -> String {
@@ -202,11 +124,8 @@ fn engram_compile(intent: String) -> String {
let act_ok: Bool = !str_eq ( activate_json, " " ) && !str_eq ( activate_json, " [] " )
let srch_ok: Bool = !str_eq ( search_json, " " ) && !str_eq ( search_json, " [] " )
// Activation nodes ( spreading activation ) are high-signal but apply scoring via
// engram_compile_ranked with threshold=5 to exclude genuinely zero-quality stale
// nodes that happen to be graph-connected. The threshold of 5 is well below the
// search path threshold of 15 to preserve the activation path 's higher recall.
let act_part: String = if act_ok { engram_compile_ranked ( activate_json, 5 ) } else { " " }
// Activation nodes ( spreading activation ) are already high-signal — keep all 5.
let act_part: String = if act_ok { activate_json } else { " " }
// Rank search results and keep only the top 8 ( was: flat 15 unranked ) .
// This cuts context noise roughly in half while preserving the best-scoring nodes.
@@ -428,11 +347,26 @@ 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 " .
// Delete-before-write under label " conv:history " prevents unbounded node accumulation ( issue # 11 ) .
fn conv_history_persist ( hist: String ) -> Void {
if str_eq ( hist, " " ) { return " " }
if str_eq ( hist, " [] " ) { return " " }
let ts: Int = time_now ( )
// Delete any existing conv:history nodes before writing to avoid accumulation.
let old_hist_results: String = engram_search_json ( " conv:history " , 3 )
let old_hist_ok: Bool = !str_eq ( old_hist_results, " " ) && !str_eq ( old_hist_results, " [] " )
if old_hist_ok {
let ohr_total: Int = json_array_len ( old_hist_results )
let ohr_i: Int = 0
while ohr_i < ohr_total {
let ohr_node: String = json_array_get ( old_hist_results, ohr_i )
let ohr_label: String = json_get ( ohr_node, " label " )
let ohr_id: String = json_get ( ohr_node, " id " )
if str_eq ( ohr_label, " conv:history " ) && !str_eq ( ohr_id, " " ) {
engram_forget ( ohr_id )
}
let ohr_i = ohr_i + 1
}
}
let tags: String = " [ \" conv-history \" , \" persistent \" ] "
let discard: String = engram_node_full (
hist, " Conversation " , " conv:history " ,
@@ -481,18 +415,25 @@ fn handle_chat(body: String) -> String {
// 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.
// Fixes issue # 6: soul_affective_context is pre-loaded at boot — use it first to
// avoid a redundant engram search and to make the boot-time state key functional.
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
} else { false }
let soul_aff_ctx : String = state_get ( " soul_affective_context " )
let found_recent : Bool = if !str_eq ( soul_aff_ctx, " " ) {
true
} else {
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
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
} else { false }
}
if found_recent {
" [RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.] \n \n "
} else { " " }
@@ -510,6 +451,49 @@ fn handle_chat(body: String) -> String {
let profile_ok: Bool = !str_eq ( profile_nodes, " " ) && !str_eq ( profile_nodes, " [] " )
let work_ok: Bool = !str_eq ( work_nodes, " " ) && !str_eq ( work_nodes, " [] " )
// Load the previous session summary. Search by label text + type, then filter by
// exact label match. Fallback: broader vector search for SessionSummary nodes.
// Fixes issue # 2: prev session summary was never loaded at startup.
// Fixes issue # 2b ( phantom engram_get_node_by_label replaced with engram_search_json ) .
let sum_search_nodes: String = engram_search_json ( " session:summary SessionSummary " , 5 )
let sum_search_ok: Bool = !str_eq ( sum_search_nodes, " " ) && !str_eq ( sum_search_nodes, " [] " )
let prev_sum_node_content: String = if sum_search_ok {
let ss_total: Int = json_array_len ( sum_search_nodes )
let ssi: Int = 0
let found_content: String = " "
while ssi < ss_total {
let ss_node: String = json_array_get ( sum_search_nodes, ssi )
let ss_label: String = json_get ( ss_node, " label " )
let ss_type: String = json_get ( ss_node, " node_type " )
let ss_content: String = json_get ( ss_node, " content " )
let found_content = if str_eq ( ss_label, " session:summary " ) && str_eq ( ss_type, " SessionSummary " ) && !str_eq ( ss_content, " " ) {
if str_eq ( found_content, " " ) { ss_content } else { found_content }
} else { found_content }
let ssi = ssi + 1
}
found_content
} else { " " }
// Check state first: soul.el pre-loads this at boot ( soul_prev_session_summary ) — fixes issue # 5.
let soul_cached_sum: String = state_get ( " soul_prev_session_summary " )
let prev_summary_raw: String = if !str_eq ( soul_cached_sum, " " ) {
soul_cached_sum
} else if !str_eq ( prev_sum_node_content, " " ) {
prev_sum_node_content
} else {
let sum_nodes: String = engram_search_json ( " SessionSummary previous-session " , 3 )
let sum_ok: Bool = !str_eq ( sum_nodes, " " ) && !str_eq ( sum_nodes, " [] " )
if sum_ok {
let sn0: String = json_array_get ( sum_nodes, 0 )
let stype: String = json_get ( sn0, " node_type " )
let scontent: String = json_get ( sn0, " content " )
if str_eq ( stype, " SessionSummary " ) && !str_eq ( scontent, " " ) { scontent } else { " " }
} else { " " }
}
let has_prev_summary: Bool = !str_eq ( prev_summary_raw, " " )
let prev_summary_snip: String = if str_len ( prev_summary_raw ) > 400 {
str_slice ( prev_summary_raw, 0 , 400 )
} else { prev_summary_raw }
// Extract content fields and render as bullet points ( one per node, first 120 chars ) .
let profile_bullets: String = if profile_ok {
let pn: Int = json_array_len ( profile_nodes )
@@ -557,15 +541,19 @@ fn handle_chat(body: String) -> String {
let has_profile: Bool = !str_eq ( profile_bullets, " " )
let has_work: Bool = !str_eq ( work_bullets, " " )
let preload: String = if has_profile || has_work {
let preload: String = if has_profile || has_work || has_prev_summary {
let summary_section: String = if has_prev_summary {
" [PREVIOUS SESSION - what we discussed last time] \n " + prev_summary_snip
} else { " " }
let profile_section: String = if has_profile {
" [USER CONTEXT — from memory] \n " + profile_bullets
" [USER CONTEXT - from memory] \n " + profile_bullets
} else { " " }
let work_section: String = if has_work {
" [ACTIVE WORK — from memory] \n " + work_bullets
" [ACTIVE WORK - from memory] \n " + work_bullets
} else { " " }
let sep_sp: String = if has_prev_summary && ( has_profile || has_work ) { " \n \n " } else { " " }
let sep_pw: String = if has_profile && has_work { " \n \n " } else { " " }
" \n \n " + profile_section + sep_pw + work_section
" \n \n " + summary_section + sep_sp + profile_section + sep_pw + work_section
} else { " " }
preload
} else { " " }
@@ -607,6 +595,14 @@ fn handle_chat(body: String) -> String {
state_set ( " conv_history " , final_hist )
conv_history_persist ( final_hist )
// Automatic session-end summary: write/overwrite the SessionSummary node on each turn
// so process restarts always have a continuity snapshot ( no shutdown hook needed ) .
// Uses autogenerate ( no LLM ) so it is cheap — the node is overwritten not appended.
let auto_sum: String = session_summary_autogenerate ( final_hist )
if !str_eq ( auto_sum, " " ) {
let discard_sum: String = session_summary_write ( auto_sum )
}
let activation_nodes: String = engram_activate_json ( message, 2 )
let act_ok: Bool = !str_eq ( activation_nodes, " " ) && !str_eq ( activation_nodes, " [] " )
let act_out: String = if act_ok { activation_nodes } else { " [] " }
@@ -1049,7 +1045,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 " ) {
@@ -1079,7 +1077,14 @@ fn handle_chat_agentic(body: String) -> String {
}
let hist_key: String = if str_eq ( req_session, " " ) { " conv_history " } else { " session_hist_ " + req_session }
let agentic_hist: String = state_get ( hist_key )
// Fall back to engram ( via session_hist_load ) when state is cold — fixes issue # 4:
// named-session history written under session:messages:SESSION_ID was never read back.
let agentic_hist_state: String = state_get ( hist_key )
let agentic_hist: String = if str_eq ( agentic_hist_state, " " ) && !str_eq ( req_session, " " ) {
let loaded: String = session_hist_load ( req_session )
if !str_eq ( loaded, " " ) { state_set ( hist_key, loaded ) }
if str_eq ( loaded, " " ) { conv_history_load ( ) } else { loaded }
} else { agentic_hist_state }
let agentic_hist_len: Int = if str_eq ( agentic_hist, " " ) { 0 } else { json_array_len ( agentic_hist ) }
let ag_is_cont: Bool = str_len ( message ) < 50 && agentic_hist_len > 0
let ag_last_entry: String = if ag_is_cont { json_array_get ( agentic_hist, agentic_hist_len - 1 ) } else { " " }
@@ -1122,6 +1127,23 @@ fn handle_chat_agentic(body: String) -> String {
let updated2: String = hist_append ( updated, " assistant " , reply_text )
let trimmed: String = if json_array_len ( updated2 ) > 20 { hist_trim ( updated2 ) } else { updated2 }
state_set ( hist_key, trimmed )
// Persist to engram for cross-restart continuity.
// Named sessions use session_hist_save ( session:messages:SESSION_ID label ) so that
// session_hist_load can recover them on the next restart — fixes issue # 4.
// The old conv:history:SESSION_ID label was a dead write ( never read back ) .
if str_eq ( hist_key, " conv_history " ) {
conv_history_persist ( trimmed )
} else {
if !str_eq ( trimmed, " " ) && !str_eq ( trimmed, " [] " ) {
session_hist_save ( req_session, trimmed )
}
}
// Write automatic session summary so cross-session continuity is maintained
// on the agentic path too — fixes issue # 7.
let ag_auto_sum: String = session_summary_autogenerate ( trimmed )
if !str_eq ( ag_auto_sum, " " ) {
let discard_ag_sum: String = session_summary_write ( ag_auto_sum )
}
true
} else { false }
@@ -1575,6 +1597,74 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
return " { \" response \" : \" " + safe_text + " \" , \" cgi_id \" : \" " + cgi_id + " \" , \" tools_used \" : " + eff_tools + " } "
}
// session_summary_write — write or overwrite the SessionSummary node in engram.
// Uses delete-before-write so there is always exactly one " session:summary " node.
// This is what session_preload at next startup reads to know what was discussed.
fn session_summary_write ( summary_text: String ) -> String {
if str_eq ( summary_text, " " ) { return " " }
let safe_text: String = str_replace ( summary_text, " \" " , " ' " )
let trimmed: String = if str_len ( safe_text ) > 800 { str_slice ( safe_text, 0 , 800 ) } else { safe_text }
let ts: Int = time_now ( )
let ts_str: String = int_to_str ( ts )
let content: String = " [session-summary] " + trimmed + " | ts: " + ts_str
// Delete old node before writing so duplicate label nodes don 't accumulate.
// engram_get_node_by_label doesn 't exist — search by label text and filter by exact match.
let old_search: String = engram_search_json ( " session:summary SessionSummary " , 5 )
let old_search_ok: Bool = !str_eq ( old_search, " " ) && !str_eq ( old_search, " [] " )
if old_search_ok {
let os_total: Int = json_array_len ( old_search )
let osi: Int = 0
while osi < os_total {
let os_node: String = json_array_get ( old_search, osi )
let os_label: String = json_get ( os_node, " label " )
let os_id: String = json_get ( os_node, " id " )
if str_eq ( os_label, " session:summary " ) && !str_eq ( os_id, " " ) {
engram_forget ( os_id )
}
let osi = osi + 1
}
}
let tags: String = " [ \" SessionSummary \" , \" session-summary \" , \" previous-session \" , \" consolidate \" ] "
let node_id: String = engram_node_full (
content, " SessionSummary " , " session:summary " ,
el_from_float ( 0.85 ) , el_from_float ( 0.85 ) , el_from_float ( 1.0 ) ,
" Episodic " , tags
)
if str_eq ( node_id, " " ) {
println ( " [chat] session_summary_write: engram write failed — summary node lost " )
return " "
}
println ( " [chat] session_summary_write: wrote SessionSummary ( " + int_to_str ( str_len ( content ) ) + " chars) -> " + node_id )
return node_id
}
// session_summary_autogenerate — build a minimal summary from conversation history without LLM.
// Extracts user message snippets ( first 80 chars each, up to 5 turns ) .
// Used as the automatic session-end hook so every turn produces a continuity snapshot.
fn session_summary_autogenerate ( hist: String ) -> String {
if str_eq ( hist, " " ) { return " " }
if str_eq ( hist, " [] " ) { return " " }
let total: Int = json_array_len ( hist )
if total == 0 { return " " }
let snippets: String = " "
let count: Int = 0
let i: Int = 0
while i < total && count < 5 {
let entry: String = json_array_get ( hist, i )
let role: String = json_get ( entry, " role " )
let msg: String = json_get ( entry, " content " )
let snip: String = if str_len ( msg ) > 80 { str_slice ( msg, 0 , 80 ) } else { msg }
// Mutations at top level of while body via if-expressions — inner if blocks don 't escape scope.
let snippets = if str_eq ( role, " user " ) && !str_eq ( snip, " " ) {
if str_eq ( snippets, " " ) { snip } else { snippets + " ; " + snip }
} else { snippets }
let count = if str_eq ( role, " user " ) && !str_eq ( snip, " " ) { count + 1 } else { count }
let i = i + 1
}
if str_eq ( snippets, " " ) { return " " }
return " Session covered: " + snippets
}
fn auto_persist ( req: String, resp: String ) -> Void {
let message: String = json_get ( req, " message " )
let reply: String = json_get ( resp, " response " )