feat(recall): cross-session-continuity improvements
Neuron Soul CI / build (pull_request) Failing after 14m49s
Neuron Soul CI / build (pull_request) Failing after 14m49s
This commit is contained in:
@@ -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
@@ -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 + "\"}"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user