feat(recall): cross-session-continuity improvements
Neuron Soul CI / build (pull_request) Failing after 14m49s

This commit is contained in:
2026-06-22 13:00:17 -05:00
parent 87c7d15b67
commit 795b32ad1a
3 changed files with 193 additions and 16 deletions
+118 -5
View File
@@ -429,6 +429,28 @@ 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. Primary: label-based fetch (stable, written
// by session_summary_write). Fallback: vector search for SessionSummary nodes.
// Fixes issue #2: prev session summary was never loaded at startup.
let prev_sum_node: String = engram_get_node_by_label("session:summary")
let prev_sum_label_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null")
let prev_summary_raw: String = if prev_sum_label_ok {
json_get(prev_sum_node, "content")
} else {
let sum_nodes: String = engram_search_json("SessionSummary session:summary 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)
@@ -476,15 +498,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 { "" }
@@ -526,6 +552,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 { "[]" }
@@ -968,7 +1002,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") {
@@ -1041,6 +1077,24 @@ 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 get session-scoped labels, fixing ephemeral-only limitation (issue #4).
if str_eq(hist_key, "conv_history") {
conv_history_persist(trimmed)
} else {
if !str_eq(trimmed, "") && !str_eq(trimmed, "[]") {
let sess_hist_label: String = "conv:history:" + req_session
let sess_hist_tags: String = "[\"session-history\",\"persistent\"]"
let sess_hist_id: String = engram_node_full(
trimmed, "Conversation", sess_hist_label,
el_from_float(0.6), el_from_float(0.7), el_from_float(0.8),
"Episodic", sess_hist_tags
)
if str_eq(sess_hist_id, "") {
println("[chat] agentic: named session history persist failed for session=" + req_session)
}
}
}
true
} else { false }
@@ -1494,6 +1548,65 @@ 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.
let old_node: String = engram_get_node_by_label("session:summary")
let old_ok: Bool = !str_eq(old_node, "") && !str_eq(old_node, "null")
if old_ok {
let old_id: String = json_get(old_node, "id")
if !str_eq(old_id, "") {
engram_forget(old_id)
}
}
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")
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
}
fn auto_persist(req: String, resp: String) -> Void {
let message: String = json_get(req, "message")
let reply: String = json_get(resp, "response")
+8 -10
View File
@@ -654,14 +654,12 @@ fn handle_api_consolidate(body: String) -> String {
engram_save(snap)
}
if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let discard: String = engram_node_full(
"[session-summary] " + safe_summary,
"SessionSummary", "session:summary",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags
)
}
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
// Use session_summary_write to ensure delete-before-write semantics:
// prevents stale SessionSummary accumulation across sessions (issue #11).
// session_summary_write handles label indexing, trimming, and dedup.
let sum_id: String = session_summary_write(summary)
if str_eq(sum_id, "") {
println("[api] consolidate: session_summary_write failed — summary not persisted")
}
} return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
}
+67 -1
View File
@@ -162,6 +162,48 @@ fn load_identity_context() -> Void {
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
}
}
// Cross-session affective context: query engram for recent distress/crisis signals.
// Broadened query includes session:emotional-summary and BellEvent tags (issue #10):
// the old keywords-only search missed these nodes when their content lacked exact phrases.
// 7-day recency window applied via the "ts" field embedded in BellEvent content.
let affective_raw: String = engram_search_json("distress crisis upset hopeless session:emotional-summary BellEvent bell:hard bell:soft", 5)
let affective_ok: Bool = !str_eq(affective_raw, "") && !str_eq(affective_raw, "[]")
if affective_ok {
let ts_now: Int = time_now()
let ts_cutoff: Int = ts_now - 604800
let aff_total: Int = json_array_len(affective_raw)
let aff_ctx: String = ""
let ai: Int = 0
while ai < aff_total {
let aff_node: String = json_array_get(affective_raw, ai)
let aff_content: String = json_get(aff_node, "content")
// Try multiple timestamp fields: "ts" (embedded), "created_at", "updated_at"
let aff_ts_str: String = json_get(aff_node, "ts")
let aff_ts_str2: String = if str_eq(aff_ts_str, "") { json_get(aff_node, "created_at") } else { aff_ts_str }
// Also try embedded " | ts:NNN" format used in BellEvent content
let ts_marker: String = " | ts:"
let ts_pos: Int = str_index_of(aff_content, ts_marker)
let aff_ts_embedded: String = if ts_pos >= 0 {
let ts_start: Int = ts_pos + str_len(ts_marker)
let rest: String = str_slice(aff_content, ts_start, str_len(aff_content))
let next_sep: Int = str_index_of(rest, " | ")
if next_sep < 0 { rest } else { str_slice(rest, 0, next_sep) }
} else { "" }
let eff_ts_str: String = if !str_eq(aff_ts_embedded, "") { aff_ts_embedded } else { aff_ts_str2 }
let aff_ts: Int = if str_eq(eff_ts_str, "") { ts_now } else { str_to_int(eff_ts_str) }
let is_recent: Bool = aff_ts >= ts_cutoff
let snip: String = if str_len(aff_content) > 200 { str_slice(aff_content, 0, 200) } else { aff_content }
let aff_ctx = if is_recent && !str_eq(snip, "") {
if str_eq(aff_ctx, "") { snip } else { aff_ctx + "\n" + snip }
} else { aff_ctx }
let ai = ai + 1
}
if !str_eq(aff_ctx, "") {
state_set("soul_affective_context", aff_ctx)
println("[soul] cross-session affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
}
}
}
// seed_persona_from_env one-time migration: SOUL_IDENTITY env var Persona graph node.
@@ -233,12 +275,36 @@ fn emit_session_start_event() -> Void {
}
let ts: Int = time_now()
// Load previous session summary at boot stash in state for session_preload (issue #6).
// Primary: label-based. Fallback: vector search. Logs it so continuity is auditable.
let prev_sum_node: String = engram_get_node_by_label("session:summary")
let prev_sum_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null")
let prev_sum_content: String = if prev_sum_ok {
json_get(prev_sum_node, "content")
} else {
let sum_search: String = engram_search_json("SessionSummary session:summary previous-session", 2)
let sum_srch_ok: Bool = !str_eq(sum_search, "") && !str_eq(sum_search, "[]")
if sum_srch_ok {
let sn: String = json_array_get(sum_search, 0)
let stype: String = json_get(sn, "node_type")
let scontent: String = json_get(sn, "content")
if str_eq(stype, "SessionSummary") && !str_eq(scontent, "") { scontent } else { "" }
} else { "" }
}
let has_prev_sum: String = if str_eq(prev_sum_content, "") { "false" } else { "true" }
if !str_eq(prev_sum_content, "") {
state_set("soul_prev_session_summary", prev_sum_content)
println("[soul] previous session summary loaded (" + int_to_str(str_len(prev_sum_content)) + " chars)")
}
let payload: String = "{\"event\":\"session_start\""
+ ",\"boot\":" + boot_num
+ ",\"cgi\":\"" + eff_cgi + "\""
+ ",\"node_count\":" + int_to_str(node_ct)
+ ",\"edge_count\":" + int_to_str(edge_ct)
+ ",\"identity_loaded\":" + has_identity
+ ",\"prev_session_summary_loaded\":" + has_prev_sum
+ ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
@@ -247,7 +313,7 @@ fn emit_session_start_event() -> Void {
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Episodic", tags
)
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")")
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + " prev_summary=" + has_prev_sum + ")")
}
// layered_cycle routes user-facing requests through the 4-layer consciousness stack.