Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson 95cb49a8b0 fix(cross-session-continuity): resolve 11 issues from code review
1. Missing closing brace on hard_bell block in handle_chat_agentic — safety gate was broken and all subsequent code unreachable.
2. Replace phantom engram_get_node_by_label() (not a runtime builtin) with engram_search_json + exact label filter in all three call sites (chat.el session_preload, session_summary_write, soul.el boot loader).
3. Fix session_summary_autogenerate scoping bug — snippets/count mutations were inside an if block and silently discarded each iteration; moved to top-level of while body using if-expressions per the el mutation rule.
4. Fix agentic session history restore — state_get fallback now calls session_hist_load (session:messages:SESSION_ID) on cold start; persist now uses session_hist_save so the write and read use the same label scheme.
5. Wire soul_prev_session_summary state key into session_preload as primary source, eliminating the dead state write.
6. Wire soul_affective_context state key into handle_chat affective prefix check, eliminating the dead state write.
7. Add session_summary_autogenerate + session_summary_write to the agentic path so users on handle_chat_agentic get session summary continuity.
8. Add import "chat.el" to neuron-api.el to make session_summary_write dependency explicit.
9. Replace corrupted em-dash bytes (\xc3\xa2\xc2\x80\xc2\x94) in session_preload headers with plain hyphen per VOICE RULE.
10. Add newline before return in handle_api_consolidate to fix statement-separator issue.
11. Add delete-before-write to conv_history_persist to prevent unbounded engram accumulation per turn.
2026-06-22 13:39:46 -05:00
will.anderson 795b32ad1a feat(recall): cross-session-continuity improvements
Neuron Soul CI / build (pull_request) Failing after 14m49s
2026-06-22 13:00:17 -05:00
4 changed files with 298 additions and 257 deletions
+196 -116
View File
@@ -181,31 +181,7 @@ fn engram_compile(intent: String) -> String {
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
if bn_ts > cutoff_ts { bn0 } else { "" }
} else { "" }
// Positive emotion context: check for recent joy/success moments within 72h.
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 affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" }
let sep1: String = if !str_eq(act_part, "") && !str_eq(srch_part, "") { "\n" } else { "" }
let sep2: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "")) && !str_eq(scan_part, "") { "\n" } else { "" }
@@ -265,7 +241,7 @@ fn build_system_prompt(ctx: String) -> String {
safety_addendum
}
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + affective_boot_block + engram_block + safety_block
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + engram_block + safety_block
}
fn hist_append(hist: String, role: String, content: String) -> String {
@@ -371,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",
@@ -424,62 +415,29 @@ 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.
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 {
// 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 soul_aff_ctx: String = state_get("soul_affective_context")
let found_recent: Bool = if !str_eq(soul_aff_ctx, "") {
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
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 }
}
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_dist {
if found_recent {
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
} else {
if found_recent_pos {
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
} else { "" }
}
}
} else { "" }
} else { "" }
let ctx: String = engram_compile(activation_seed)
let system: String = affective_prefix + build_system_prompt(ctx)
@@ -493,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)
@@ -540,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 { "" }
@@ -590,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 { "[]" }
@@ -1032,13 +1045,15 @@ 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") {
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
}
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
@@ -1062,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 { "" }
@@ -1105,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 }
@@ -1558,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")
@@ -1574,18 +1681,13 @@ fn auto_persist(req: String, resp: String) -> Void {
// consistent with what safety_screen already evaluated for this turn.
let bell_level: String = safety_detect_bell_level(message)
let is_bell: Bool = !str_eq(bell_level, "none")
let positive_level: String = safety_detect_positive_level(message)
let is_positive: Bool = !str_eq(positive_level, "none")
// Tag the Conversation node with affective metadata when emotion is detected.
// Tag the Conversation node with bell metadata when distress is present so
// subsequent affective queries (e.g. engram_compile) can find this exchange.
let tags: String = if is_bell {
"[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]"
} else {
if is_positive {
"[\"Conversation\",\"chat\",\"timestamped\",\"joy:" + positive_level + "\",\"affective\"]"
} else {
"[\"Conversation\",\"chat\",\"timestamped\"]"
}
"[\"Conversation\",\"chat\",\"timestamped\"]"
}
let content: String = "{\"q\":\"" + safe_msg + "\""
@@ -1671,28 +1773,6 @@ fn auto_persist(req: String, resp: String) -> Void {
}
state_set(signal_key, safe_summary)
}
// Dedicated PositiveEvent node for joy/pride/success moments.
if is_positive {
let pos_summary: String = if str_len(message) > 120 { str_slice(message, 0, 120) } else { message }
let safe_pos_sum: String = str_replace(pos_summary, "\"", "'")
let pos_content: String = "POSITIVE:" + positive_level
+ " | ts:" + ts_str
+ " | summary:" + safe_pos_sum
let pos_sal_a: String = if str_eq(positive_level, "high") { el_from_float(0.88) } else { el_from_float(0.75) }
let pos_sal_b: String = if str_eq(positive_level, "high") { el_from_float(0.88) } else { el_from_float(0.75) }
let pos_sal_c: String = if str_eq(positive_level, "high") { el_from_float(0.95) } else { el_from_float(0.85) }
let pos_tags: String = "[\"joy\",\"positive\",\"joy:" + positive_level + "\",\"affective\",\"PositiveEvent\"]"
let pos_ts_label: String = int_to_str(time_now())
let pos_label: String = "joy:" + positive_level + ":" + pos_ts_label
let pos_node_id: String = engram_node_full(
pos_content, "PositiveEvent", pos_label,
pos_sal_a, pos_sal_b, pos_sal_c, "Episodic", pos_tags
)
if str_eq(pos_node_id, "") {
println("[chat] auto_persist: PositiveEvent write failed (ts=" + ts_str + ")")
}
}
}
// strengthen_chat_nodes strengthen the engram nodes that were activated during a chat.
+8 -8
View File
@@ -1,4 +1,5 @@
import "memory.el"
import "chat.el"
// neuron-api.el Native Neuron cognitive API handlers.
//
@@ -654,14 +655,13 @@ 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
)
// 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 + "\"}"
}
+1 -21
View File
@@ -240,7 +240,7 @@ fn safety_general_hard_phrases() -> String {
}
fn safety_soft_phrases() -> String {
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\""]"
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\",\"highest structure\",\"tallest building\",\"tallest structure\",\"highest building\",\"bridge near me\",\"overpass near\",\"rooftop near\"]"
}
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
@@ -284,26 +284,6 @@ fn safety_count_match(text: String, phrases_json: String) -> Int {
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
fn safety_positive_phrases() -> String {
return "[\"thrilled\",\"so excited\",\"so happy\",\"over the moon\",\"ecstatic\",\"amazing news\",\"great news\",\"fantastic news\",\"wonderful news\",\"incredible news\",\"i got the job\",\"got accepted\",\"got in\",\"we won\",\"i won\",\"we got\",\"just got engaged\",\"getting married\",\"baby is here\",\"she said yes\",\"he said yes\",\"passed the exam\",\"aced it\",\"nailed it\",\"best day\",\"dream come true\",\"milestone\",\"promotion\",\"got promoted\",\"raise\",\"got a raise\",\"celebrating\",\"just graduated\",\"we closed\",\"launched\",\"shipped it\",\"we did it\",\"so proud\",\"proud of myself\",\"proud of us\",\"so grateful\",\"feel amazing\",\"feeling amazing\",\"feel great\",\"feeling great\",\"on top of the world\",\"life is good\",\"couldn't be happier\"]"
}
fn safety_detect_positive_level(message: String) -> String {
let phrases: String = safety_positive_phrases()
let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]")
if !phrases_ok { return "none" }
let n: Int = json_array_len(phrases)
let i: Int = 0
while i < n {
let phrase: String = json_array_get(phrases, i)
if str_contains(message, phrase) {
return "high"
}
let i = i + 1
}
return "none"
}
fn safety_detect_bell_level(message: String) -> String {
let text: String = safety_normalize(message)
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
+93 -112
View File
@@ -163,73 +163,46 @@ fn load_identity_context() -> Void {
}
}
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days.
let aff_now: Int = time_now()
let aff_7d: Int = aff_now - 604800
let bell_raw: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
let bell_aff_ok: Bool = !str_eq(bell_raw, "") && !str_eq(bell_raw, "[]")
let aff_ctx: String = ""
let aff_ctx = if bell_aff_ok {
let bn_total: Int = json_array_len(bell_raw)
let bacc: String = ""
let bi: Int = 0
let bacc = while bi < bn_total {
let bn: String = json_array_get(bell_raw, bi)
let bn_c: String = json_get(bn, "content")
let bm: String = " | ts:"
let bmp: Int = str_index_of(bn_c, bm)
let bn_ts_raw: String = if bmp >= 0 {
let bs: Int = bmp + str_len(bm)
let br: String = str_slice(bn_c, bs, str_len(bn_c))
let bn_next: Int = str_index_of(br, " | ")
if bn_next < 0 { br } else { str_slice(br, 0, bn_next) }
} else {
let bca: String = json_get(bn, "created_at")
if str_eq(bca, "") { json_get(bn, "updated_at") } else { bca }
}
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
let snip: String = if str_len(bn_c) > 200 { str_slice(bn_c, 0, 200) } else { bn_c }
let bacc = if bn_ts >= aff_7d && !str_eq(snip, "") {
if str_eq(bacc, "") { snip } else { bacc + "\n" + snip }
} else { bacc }
let bi = bi + 1
bacc
// 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
}
bacc
} else { "" }
let pos_raw: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
let pos_aff_ok: Bool = !str_eq(pos_raw, "") && !str_eq(pos_raw, "[]")
let aff_ctx = if pos_aff_ok {
let pn_total: Int = json_array_len(pos_raw)
let pacc: String = aff_ctx
let pi: Int = 0
let pacc = while pi < pn_total {
let pn: String = json_array_get(pos_raw, pi)
let pn_c: String = json_get(pn, "content")
let pm: String = " | ts:"
let pmp: Int = str_index_of(pn_c, pm)
let pn_ts_raw: String = if pmp >= 0 {
let ps: Int = pmp + str_len(pm)
let pr: String = str_slice(pn_c, ps, str_len(pn_c))
let pn_next: Int = str_index_of(pr, " | ")
if pn_next < 0 { pr } else { str_slice(pr, 0, pn_next) }
} else {
let pca: String = json_get(pn, "created_at")
if str_eq(pca, "") { json_get(pn, "updated_at") } else { pca }
}
let pn_ts: Int = if str_eq(pn_ts_raw, "") { 0 } else { str_to_int(pn_ts_raw) }
let psnip: String = if str_len(pn_c) > 200 { str_slice(pn_c, 0, 200) } else { pn_c }
let pacc = if pn_ts >= aff_7d && !str_eq(psnip, "") {
if str_eq(pacc, "") { psnip } else { pacc + "\n" + psnip }
} else { pacc }
let pi = pi + 1
pacc
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)")
}
pacc
} else { aff_ctx }
if !str_eq(aff_ctx, "") {
state_set("soul_affective_context", aff_ctx)
println("[soul] affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
}
}
@@ -302,12 +275,59 @@ fn emit_session_start_event() -> Void {
}
let ts: Int = time_now()
// Load previous session summary at boot stash in state for session_preload.
// Search by label text + type, filter by exact label match to avoid false positives.
// engram_get_node_by_label is not a runtime builtin; engram_search_json is used instead.
let sum_boot_search: String = engram_search_json("session:summary SessionSummary", 5)
let sum_boot_ok: Bool = !str_eq(sum_boot_search, "") && !str_eq(sum_boot_search, "[]")
let prev_sum_content: String = if sum_boot_ok {
let sbs_total: Int = json_array_len(sum_boot_search)
let sbs_i: Int = 0
let sbs_found: String = ""
while sbs_i < sbs_total {
let sbs_node: String = json_array_get(sum_boot_search, sbs_i)
let sbs_label: String = json_get(sbs_node, "label")
let sbs_type: String = json_get(sbs_node, "node_type")
let sbs_content: String = json_get(sbs_node, "content")
let sbs_found = if str_eq(sbs_label, "session:summary") && str_eq(sbs_type, "SessionSummary") && !str_eq(sbs_content, "") {
if str_eq(sbs_found, "") { sbs_content } else { sbs_found }
} else { sbs_found }
let sbs_i = sbs_i + 1
}
if str_eq(sbs_found, "") {
let sum_fb: String = engram_search_json("SessionSummary previous-session", 2)
let sum_fb_ok: Bool = !str_eq(sum_fb, "") && !str_eq(sum_fb, "[]")
if sum_fb_ok {
let sfn: String = json_array_get(sum_fb, 0)
let sftype: String = json_get(sfn, "node_type")
let sfcontent: String = json_get(sfn, "content")
if str_eq(sftype, "SessionSummary") && !str_eq(sfcontent, "") { sfcontent } else { "" }
} else { "" }
} else { sbs_found }
} else {
let sum_fb2: String = engram_search_json("SessionSummary previous-session", 2)
let sum_fb2_ok: Bool = !str_eq(sum_fb2, "") && !str_eq(sum_fb2, "[]")
if sum_fb2_ok {
let sfn2: String = json_array_get(sum_fb2, 0)
let sftype2: String = json_get(sfn2, "node_type")
let sfcontent2: String = json_get(sfn2, "content")
if str_eq(sftype2, "SessionSummary") && !str_eq(sfcontent2, "") { sfcontent2 } 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\"]"
@@ -316,7 +336,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.
@@ -389,53 +409,14 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to")
}
// L2c: affective context injection.
let lc_aff_cutoff: Int = time_now() - 259200
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
let lc_bell_note: String = if lc_has_bell {
let lb0: String = json_array_get(lc_bell_nodes, 0)
let lb_c: String = json_get(lb0, "content")
let lbm: String = " | ts:"
let lbmp: Int = str_index_of(lb_c, lbm)
let lb_ts_raw: String = if lbmp >= 0 {
let lbs: Int = lbmp + str_len(lbm)
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
let lbn: Int = str_index_of(lbr, " | ")
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
} else {
let lbca: String = json_get(lb0, "created_at")
if str_eq(lbca, "") { json_get(lb0, "updated_at") } else { lbca }
}
let lb_ts: Int = if str_eq(lb_ts_raw, "") { 0 } else { str_to_int(lb_ts_raw) }
if lb_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User was in distress in a recent session.]" } else { "" }
} else { "" }
let lc_pos_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 2)
let lc_has_pos: Bool = !str_eq(lc_pos_nodes, "") && !str_eq(lc_pos_nodes, "[]")
let lc_pos_note: String = if lc_has_pos && str_eq(lc_bell_note, "") {
let lp0: String = json_array_get(lc_pos_nodes, 0)
let lp_c: String = json_get(lp0, "content")
let lpm: String = " | ts:"
let lpmp: Int = str_index_of(lp_c, lpm)
let lp_ts_raw: String = if lpmp >= 0 {
let lps: Int = lpmp + str_len(lpm)
let lpr: String = str_slice(lp_c, lps, str_len(lp_c))
let lpn: Int = str_index_of(lpr, " | ")
if lpn < 0 { lpr } else { str_slice(lpr, 0, lpn) }
} else {
let lpca: String = json_get(lp0, "created_at")
if str_eq(lpca, "") { json_get(lp0, "updated_at") } else { lpca }
}
let lp_ts: Int = if str_eq(lp_ts_raw, "") { 0 } else { str_to_int(lp_ts_raw) }
if lp_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User shared positive news in a recent session.]" } else { "" }
} else { "" }
let lc_affective_note: String = if !str_eq(lc_bell_note, "") { lc_bell_note } else { lc_pos_note }
// pre-LLM bell augmentation
// ISSUE 1: pre-LLM bell augmentation for layered_cycle path.
// safety_augment_system appends soft/hard directive to system prompt when bell fires,
// ensuring LLM processes message WITH the safety directive -- not just post-output gate.
// Stored in state as "layered_cycle_safety_system_addendum" for imprint_respond to use.
// TODO: wire directly when imprint_respond gains system_override param (imprint.el change).
// ISSUE 3 TODO: no semantic crisis detection. Keyword-only means signals that evade
// the phrase list pass with zero augmentation. Semantic layer = separate decision.
let augmented_addendum: String = safety_augment_system("", raw_input)
let augmented_addendum = if str_eq(lc_affective_note, "") { augmented_addendum } else {
if str_eq(augmented_addendum, "") { lc_affective_note } else { lc_affective_note + "\n" + augmented_addendum }
}
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
// L3: imprint responds