Compare commits

..

1 Commits

Author SHA1 Message Date
will.anderson 0113407728 feat(recall): emotional-recall improvements
Neuron Soul CI / build (pull_request) Has been cancelled
2026-06-22 13:17:12 -05:00
3 changed files with 279 additions and 228 deletions
+144 -198
View File
@@ -12,66 +12,34 @@ fn chat_default_model() -> String {
return "claude-sonnet-4-5"
}
// engram_numeric_valid guard for str_to_int: returns true only when s is a valid
// decimal number (integer or single-decimal-point float, optional leading minus).
// Q1 fix: rejects "", "null", "N/A", multi-dot strings ("1.2.3"), pure-letter strings.
// Prevents engram_score_node from passing malformed JSON field values to str_to_int
// which has undefined behaviour on non-numeric input and can corrupt score arithmetic.
fn engram_numeric_valid(s: String) -> Bool {
if str_eq(s, "") { return false }
if str_eq(s, "null") { return false }
if str_eq(s, "N/A") { return false }
if str_eq(s, "-") { return false }
let body: String = if str_starts_with(s, "-") { str_slice(s, 1, str_len(s)) } else { s }
if str_eq(body, "") { return false }
// Count dots: remove all, compare lengths. Allow at most one dot (float).
let no_dot: String = str_replace(body, ".", "")
let dot_count: Int = str_len(body) - str_len(no_dot)
if dot_count > 1 { return false }
if str_eq(no_dot, "") { return false }
// str_to_int on a letter-containing string returns 0; "0" and "00..." (e.g. from "0.0")
// are valid zeros. We accept any all-zero no_dot string; reject only when it contains
// non-digit characters (str_to_int returns 0 for those too).
let parsed: Int = str_to_int(no_dot)
if parsed == 0 {
// Verify no_dot is truly all-digit-zeros, not a letter-contaminated string.
// Strip all '0' characters; if anything remains the string is non-numeric.
let stripped_zeros: String = str_replace(no_dot, "0", "")
if !str_eq(stripped_zeros, "") { return false }
}
return true
}
// engram_score_node compute a recency x relevance score for a single engram
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5.
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
// nodes to the bottom so they get trimmed when we cap context size.
// Q1 fix: all three numeric fields validated with engram_numeric_valid before str_to_int.
fn engram_score_node(node_json: String) -> Int {
let salience_str: String = json_get(node_json, "salience")
let importance_str: String = json_get(node_json, "importance")
let created_str: String = json_get(node_json, "created_at")
// Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults.
// Parse as floats via * 100 integer arithmetic (el has no float math).
let salience_100: Int = if !engram_numeric_valid(salience_str) { 70 } else {
// 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 !engram_numeric_valid(importance_str) { 70 } else {
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 } }
}
// 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 !engram_numeric_valid(created_str) { 50 } else {
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
// Q1 fix: guard against clock skew / future timestamps treat as fresh.
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
let 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 }
}
@@ -148,23 +116,13 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
return c9
}
// Q4 note: engram_compile has no cache or circuit-breaker at the EL layer.
// Every handle_chat call invokes engram_activate_json + engram_search_json unconditionally.
// If the engram backend is repeatedly unreachable (e.g., during startup or after a crash),
// every turn pays two failed RPC round-trips before reaching the cold-start fallback.
// A proper cache/circuit-breaker requires C runtime support (e.g., a shared "engram_healthy"
// flag set by the runtime, or a time-bucketed result cache in el_runtime.c). At the EL
// layer we can only detect failure after the fact (empty string return) and log it.
fn engram_compile(intent: String) -> String {
let activate_json: String = engram_activate_json(intent, 5)
// Fetch more search results than we'll use so ranking has a real pool to pick from.
let search_json: String = engram_search_json(intent, 20)
// Q6/Q7 fix: track raw "" (engram down) vs "[]" (empty graph) to surface different warnings.
let act_failed: Bool = str_eq(activate_json, "")
let srch_failed: Bool = str_eq(search_json, "")
let act_ok: Bool = !act_failed && !str_eq(activate_json, "[]")
let srch_ok: Bool = !srch_failed && !str_eq(search_json, "[]")
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 already high-signal keep all 5.
let act_part: String = if act_ok { activate_json } else { "" }
@@ -174,37 +132,23 @@ fn engram_compile(intent: String) -> String {
let srch_ranked: String = if srch_ok { engram_compile_ranked(search_json, 8) } else { "" }
let srch_part: String = srch_ranked
// Q2 fix: soul-agnostic cold-start fallback. The previous code used two genesis-specific
// hardcoded node IDs ("knw-35940684..." and "knw-729fc901..."). Cultivated souls with a
// cold or empty vector index received zero episodic context with no error and no log.
// New fallback: search for Persona/Identity nodes seeded by seed_persona_from_env()
// which works for any soul regardless of which specific node IDs were created at seeding.
// Q6 fix: log a warning so the empty-recall path is visible in operator logs.
// Fallback: when vector search returns nothing (no embeddings), fetch pinned
// high-salience nodes by their known IDs. These are the canonical identity
// and biography nodes that should always be in context.
// engram_get_node_json(id) returns a single node as JSON or "" if missing.
let scan_part: String = if !act_ok && !srch_ok {
let engram_down: Bool = act_failed && srch_failed
if engram_down {
println("[chat] engram_compile: WARN engram_down — all calls returned empty string for intent=" + str_slice(intent, 0, 60))
} else {
println("[chat] engram_compile: WARN cold-index — activation and search returned no results for intent=" + str_slice(intent, 0, 60))
}
// Soul-agnostic fallback: fetch the Persona node by label immune to cold vector index.
// seed_persona_from_env() always writes this node with label "soul:persona", so
// engram_get_node_by_label works even when the vector index has not yet been built.
// Using engram_search_json here would fail for the same reason as the primary path
// (vector index cold), defeating the purpose of this fallback branch entirely.
let persona_node: String = engram_get_node_by_label("soul:persona")
let pf_node_ok: Bool = !str_eq(persona_node, "") && !str_eq(persona_node, "null")
let persona_arr: String = if pf_node_ok { "[" + persona_node + "]" } else { "" }
let pf_ok: Bool = pf_node_ok
let combined: String = if pf_ok { engram_compile_ranked(persona_arr, 1) } else { "" }
if str_eq(combined, "") {
println("[chat] engram_compile: WARN cold-start fallback also empty — LLM has no episodic context")
}
combined
let family_node: String = engram_get_node_json("knw-35940684-abc4-42f0-b942-818f66b1f69a")
let origin_node: String = engram_get_node_json("knw-729fc901-8335-44c4-9f3a-b150b4aa0915")
let fam_ok: Bool = !str_eq(family_node, "") && !str_eq(family_node, "null")
let orig_ok: Bool = !str_eq(origin_node, "") && !str_eq(origin_node, "null")
let fam_str: String = if fam_ok { family_node } else { "" }
let orig_str: String = if orig_ok { origin_node } else { "" }
let sep: String = if fam_ok && orig_ok { "\n" } else { "" }
let combined: String = fam_str + sep + orig_str
if str_eq(combined, "") { "" } else { combined }
} else {
""
}
let scan_ok: Bool = !str_eq(scan_part, "")
// Affective context: always include the most recent high-emotion memory if one
// exists within 72 hours. This ensures continuity of care across turns when
@@ -234,31 +178,41 @@ fn engram_compile(intent: String) -> String {
let ca: String = json_get(bn0, "created_at")
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
}
// Q1 fix: validate bell timestamp before str_to_int.
let bn_ts: Int = if !engram_numeric_valid(bn_ts_raw) { 0 } else { str_to_int(bn_ts_raw) }
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 { "" }
let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" }
let affective_ok: Bool = !str_eq(affective_part, "")
// 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 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 { "" }
let sep3: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "") || !str_eq(scan_part, "")) && !str_eq(affective_part, "") { "\n" } else { "" }
let ctx: String = act_part + sep1 + srch_part + sep2 + scan_part + sep3 + affective_part
// Q7 fix: store recall status so build_system_prompt can include a hint to the LLM
// distinguishing "no memories yet" (cold start) from "memory system unreachable".
// Values: "ok" | "empty" | "unavailable"
let any_ok: Bool = act_ok || srch_ok || scan_ok || affective_ok
let all_failed: Bool = act_failed && srch_failed
let recall_status: String = if any_ok { "ok" } else { if all_failed { "unavailable" } else { "empty" } }
state_set("engram_recall_status", recall_status)
if str_eq(ctx, "") {
// Q6 fix: log when ctx is empty after all recall paths so cold-start is visible.
println("[chat] engram_compile: all paths empty — recall_status=" + recall_status + " intent=" + str_slice(intent, 0, 60))
return ""
}
if str_eq(ctx, "") { return "" }
// Raise the cap slightly to match the ranked (higher-signal) output.
if str_len(ctx) > 6000 {
@@ -297,33 +251,12 @@ fn build_system_prompt(ctx: String) -> String {
"\n\n[IDENTITY GRAPH — who you are, loaded from your engram]\n" + id_ctx
}
// Q7 fix: if recall produced no results, include a hint so the LLM can respond
// authentically ("I seem to be starting fresh" vs "memory system may be down")
// rather than silently acting as if it has context it doesn't have.
// Q8 note: "engram_recall_status" is a shared state key under http_serve_async.
// Concurrent requests can overwrite each other's status. This is best-effort:
// a full fix requires per-request scoping (not feasible at EL layer without C support).
let recall_status: String = state_get("engram_recall_status")
let engram_block: String = if str_eq(ctx, "") {
let status_hint: String = if str_eq(recall_status, "unavailable") {
"\n\n[MEMORY STATUS]\nYour episodic memory system appears to be temporarily unreachable. You may not have access to memories from previous sessions. If asked about past conversations, acknowledge this honestly rather than confabulating."
} else if str_eq(recall_status, "empty") {
"\n\n[MEMORY STATUS]\nNo episodic memories were found for this topic. This may be a new soul or a new area of conversation. Respond naturally from your identity without fabricating memories."
} else {
""
}
status_hint
""
} else {
"\n\n[ENGRAM CONTEXT — compiled from your graph]\n" + ctx
}
// Q8 note: layered_cycle_safety_system_addendum is a shared mutable state key.
// Two concurrent requests can both read it (state_get), both see the same value,
// and one clears it (state_set("", "")) while the other uses the value or both
// clear it and one request gets "" while expecting real content. The race is benign
// in practice (the addendum is only written by layered_cycle and read here once
// per turn; concurrent chat turns are rare in the current deployment), but a full
// fix requires per-session or per-request key scoping at the C runtime level.
let safety_addendum: String = state_get("layered_cycle_safety_system_addendum")
let safety_block: String = if str_eq(safety_addendum, "") {
""
@@ -332,7 +265,7 @@ fn build_system_prompt(ctx: String) -> String {
safety_addendum
}
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + engram_block + safety_block
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + affective_boot_block + engram_block + safety_block
}
fn hist_append(hist: String, role: String, content: String) -> String {
@@ -438,82 +371,41 @@ fn clean_llm_response(s: String) -> String {
}
// conv_history_persist save conversation history to engram for cross-restart continuity.
// Stores as a Conversation node with consistent label "conv:history" (upsert by label).
// Q3/Q6 fix: added partial-write guard and failure logging.
// Stores as a Conversation node. Overwrites by using consistent label "conv:history".
fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "") { return "" }
if str_eq(hist, "[]") { return "" }
// Partial-write guard: refuse to persist a blob that is not a complete JSON array.
// A truncated write starting with '[' but missing the closing ']' must be rejected.
// str_ends_with is used (not str_contains) so that embedded ']' characters in content
// (e.g. "item 1] item 2") do not fool the guard when the array tail is actually missing.
if !str_starts_with(hist, "[") { return "" }
if !str_ends_with(hist, "]") { return "" }
let ts: Int = time_now()
let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full(
let discard: String = engram_node_full(
hist, "Conversation", "conv:history",
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags
)
// Q6 fix: log write failure silent history loss is now visible.
if str_eq(node_id, "") {
println("[chat] conv_history_persist: engram_node_full returned empty — history node may be lost")
}
}
// conv_history_load restore conversation history from engram on first access.
// Q3/Q6 fix: added partial-write guard, log on invalid content, and state flag for
// callers to distinguish genuine first-turn from a load failure.
// Returns the most recent "conv:history" node content, or "" if none found.
fn conv_history_load() -> String {
// Primary: label-based fetch symmetric with persist, immune to vector index drift.
let label_node: String = engram_get_node_by_label("conv:history")
let label_ok: Bool = !str_eq(label_node, "") && !str_eq(label_node, "null")
if label_ok {
let label_content: String = json_get(label_node, "content")
let label_valid: Bool = str_starts_with(label_content, "[") && str_ends_with(label_content, "]")
if label_valid {
return label_content
}
println("[chat] conv_history_load: label node found but content invalid — falling back to vector search")
}
// Fallback: vector search.
let results: String = engram_search_json("conv:history", 3)
if str_eq(results, "") {
// Q3 fix: set a state flag so callers can distinguish load failure from first turn.
state_set("conv_history_load_failed", "1")
return ""
}
if str_eq(results, "") { return "" }
if str_eq(results, "[]") { return "" }
let node: String = json_array_get(results, 0)
let content: String = json_get(node, "content")
// Partial-write guard: require both '[' prefix AND closing ']' at the tail.
// str_ends_with guards against embedded ']' in content fooling the check.
if !str_starts_with(content, "[") || !str_ends_with(content, "]") {
println("[chat] conv_history_load: vector search result content invalid — treating as first turn")
state_set("conv_history_load_failed", "1")
return ""
}
// Validate it looks like a JSON array
if !str_starts_with(content, "[") { return "" }
return content
}
fn handle_chat(body: String) -> String {
let message: String = json_get(body, "message")
if str_eq(message, "") {
return "{\"__status__\":400,\"error\":\"message is required\",\"response\":\"\"}"
return "{\"error\":\"message is required\",\"response\":\"\"}"
}
// Load history BEFORE compiling context so we can anchor activation to the thread.
// Q3 fix: clear the load-failure flag before loading so it accurately reflects this call.
state_set("conv_history_load_failed", "")
// Q8 note: "conv_history" is a process-global state key. Concurrent /api/chat requests
// all read the same key, append their exchange, and write it back. Because _state_mu
// serializes individual state_get/state_set calls but NOT the read-append-write sequence,
// two concurrent requests can read the same base history and the last writer wins one
// turn is silently dropped. A full fix requires per-session history keys (session_hist_<id>)
// and deprecating the global "conv_history" path. Callers using session_id are not affected.
let state_hist: String = state_get("conv_history")
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
let hist_load_failed: Bool = str_eq(state_get("conv_history_load_failed"), "1")
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
// Thread-aware activation: short/ambiguous messages (continuations like "go on",
@@ -532,22 +424,62 @@ 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 = 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
let affective_prefix: String = {
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
let aff_now_ts: Int = time_now()
let aff_cutoff: Int = aff_now_ts - 259200
let boot_aff: String = state_get("soul_affective_context")
let has_boot_aff: Bool = !str_eq(boot_aff, "")
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
let found_recent_dist: Bool = if has_boot_aff {
true
} else {
if has_dist_aff {
let dn0: String = json_array_get(dist_nodes_aff, 0)
let dn_content: String = json_get(dn0, "content")
let daff_marker: String = " | ts:"
let daff_pos: Int = str_index_of(dn_content, daff_marker)
let daff_ts_str: String = if daff_pos >= 0 {
let daff_start: Int = daff_pos + str_len(daff_marker)
let daff_rest: String = str_slice(dn_content, daff_start, str_len(dn_content))
let daff_next: Int = str_index_of(daff_rest, " | ")
if daff_next < 0 { daff_rest } else { str_slice(daff_rest, 0, daff_next) }
} else {
let daff_ca: String = json_get(dn0, "created_at")
if str_eq(daff_ca, "") { json_get(dn0, "updated_at") } else { daff_ca }
}
let daff_ts: Int = if str_eq(daff_ts_str, "") { 0 } else { str_to_int(daff_ts_str) }
daff_ts > aff_cutoff
} else { false }
}
let pos_nodes_aff: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
let has_pos_aff: Bool = !str_eq(pos_nodes_aff, "") && !str_eq(pos_nodes_aff, "[]")
let found_recent_pos: Bool = if has_pos_aff && !found_recent_dist {
let pn0: String = json_array_get(pos_nodes_aff, 0)
let pn_content: String = json_get(pn0, "content")
let paff_marker: String = " | ts:"
let paff_pos: Int = str_index_of(pn_content, paff_marker)
let paff_ts_str: String = if paff_pos >= 0 {
let paff_start: Int = paff_pos + str_len(paff_marker)
let paff_rest: String = str_slice(pn_content, paff_start, str_len(pn_content))
let paff_next: Int = str_index_of(paff_rest, " | ")
if paff_next < 0 { paff_rest } else { str_slice(paff_rest, 0, paff_next) }
} else {
let paff_ca: String = json_get(pn0, "created_at")
if str_eq(paff_ca, "") { json_get(pn0, "updated_at") } else { paff_ca }
}
let paff_ts: Int = if str_eq(paff_ts_str, "") { 0 } else { str_to_int(paff_ts_str) }
paff_ts > aff_cutoff
} else { false }
if found_recent {
if found_recent_dist {
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
} else { "" }
} else { "" }
} else {
if found_recent_pos {
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
} else { "" }
}
}
let ctx: String = engram_compile(activation_seed)
let system: String = affective_prefix + build_system_prompt(ctx)
@@ -663,13 +595,7 @@ fn handle_chat(body: String) -> String {
let act_out: String = if act_ok { activation_nodes } else { "[]" }
strengthen_chat_nodes(act_out)
// Q3 fix: surface history load failure in the response envelope so callers can
// show a "starting fresh — could not load previous conversation" indicator.
let hist_warning: String = if hist_load_failed {
",\"history_load_failed\":true"
} else { "" }
return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + hist_warning + "}"
return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + "}"
}
fn handle_see(body: String) -> String {
@@ -1112,7 +1038,7 @@ fn handle_chat_agentic(body: String) -> String {
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 }
@@ -1648,13 +1574,18 @@ 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 bell metadata when distress is present so
// subsequent affective queries (e.g. engram_compile) can find this exchange.
// Tag the Conversation node with affective metadata when emotion is detected.
let tags: String = if is_bell {
"[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]"
} else {
"[\"Conversation\",\"chat\",\"timestamped\"]"
if is_positive {
"[\"Conversation\",\"chat\",\"timestamped\",\"joy:" + positive_level + "\",\"affective\"]"
} else {
"[\"Conversation\",\"chat\",\"timestamped\"]"
}
}
let content: String = "{\"q\":\"" + safe_msg + "\""
@@ -1674,13 +1605,6 @@ fn auto_persist(req: String, resp: String) -> Void {
"Episodic",
tags
)
// CRITICAL BUG fix: log conv_node_id failure OUTSIDE the is_bell block.
// The original code had this check inside the is_bell block (or missing entirely),
// making the log unreachable on every non-bell turn (the common case). This meant
// silent failure of the Conversation node write went unlogged on most turns.
if str_eq(conv_node_id, "") {
println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")")
}
// When a bell fires, write a dedicated BellEvent node in addition to the
// Conversation node. This makes distress moments directly findable by label
@@ -1747,6 +1671,28 @@ 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.
+21 -1
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\",\"highest structure\",\"tallest building\",\"tallest structure\",\"highest building\",\"bridge near me\",\"overpass near\",\"rooftop near\"]"
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\""]"
}
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
@@ -284,6 +284,26 @@ 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())
+114 -29
View File
@@ -148,14 +148,6 @@ fn load_identity_context() -> Void {
println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)")
}
// Q6 fix: warn when all three identity node fetches return empty. For genesis this
// indicates a corrupted or missing graph. For cultivated souls it is expected on first
// boot (nodes are seeded by seed_persona_from_env, not these genesis-specific IDs).
// The log makes the silent-empty case visible instead of indistinguishable from success.
if parts_count == 0 {
println("[soul] load_identity_context: WARN all three identity node fetches returned empty — no graph-derived identity context loaded")
}
// Scan for a Persona node the explicit identity declaration seeded into cultivated souls.
// Stored at seeding time with label "soul:persona" and node_type "Persona".
// genesis derives identity from the graph directly; cultivated souls have this node seeded.
@@ -170,11 +162,74 @@ fn load_identity_context() -> Void {
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
}
}
// Q6 fix: if neither identity nodes nor persona node were loaded, log explicitly.
let soul_id_ctx: String = state_get("soul_identity_context")
let soul_persona_ctx: String = state_get("soul_persona")
if str_eq(soul_id_ctx, "") && str_eq(soul_persona_ctx, "") {
println("[soul] load_identity_context: WARN no identity context available from graph — soul will have identity_block empty in system prompts")
// 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
}
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
}
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)")
}
}
@@ -334,23 +389,53 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to")
}
// 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.
//
// Q8 race documentation: "layered_cycle_safety_system_addendum" is a shared process-global
// state key. Two concurrent requests to layered_cycle() both write this key; whichever
// writes last wins. The concurrent build_system_prompt() read in chat.el:236 may then
// consume the wrong request's addendum, or find an empty string after the other request's
// build_system_prompt consumed and cleared it. Mitigation: under http_serve_async, the
// layered_cycle path and the /api/chat path are different endpoints (typically); true
// concurrent layered_cycle calls are uncommon. A robust fix requires per-request state
// scoping which needs C runtime support (e.g. a request-id-keyed addendum map).
// 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
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