Compare commits

..

1 Commits

Author SHA1 Message Date
will.anderson 715dea0f44 fix(emotional-recall): resolve all remaining code review issues
Issue 1: declare affective_boot_block in build_system_prompt by reading
soul_affective_context from state — the variable was used in the return
statement but never assigned, causing a runtime undefined-variable error
on every call.

Issue 2: add missing closing brace for the hard_bell if-block in
handle_chat_agentic — the absent '}' made the entire function body after
the return syntactically invalid.

Issue 3: call safety_normalize() before matching in
safety_detect_positive_level — all phrases are lowercase; without
normalization "I GOT THE JOB", "Thrilled!", and "We Won" never matched.

Issue 4: switch json_array_get to json_array_get_string in
safety_detect_positive_level, matching the helpers used by safety_any_match
and safety_count_match throughout the rest of the safety infrastructure.

Issue 5: remove the explicit safety_log_bell call in handle_chat_agentic
hard_bell branch — safety_screen() already logs internally, so the call
produced two BellEvent nodes per hard bell on the agentic path.

Issue 6: already fixed on this branch (conv_history key confirmed correct).

Issue 7: emit "low" for a single positive-phrase match and "high" for two
or more — the detector previously only returned "high" or "none", making
the "low" branch in auto_persist and the joy:low engram tag unreachable.
2026-06-22 13:39:14 -05:00
2 changed files with 73 additions and 194 deletions
+57 -190
View File
@@ -35,41 +35,6 @@ fn engram_numeric_valid(s: String) -> Bool {
return true
}
// parse_float_x100 parse a float string like "0.85", "0.9", "1.0" into an integer
// scaled by 100 (so "0.85" -> 85, "0.9" -> 90, "1.0" -> 100). Uses only integer
// arithmetic because el has no float math. Normalises to exactly 2 decimal digits
// before stripping the dot so 1-decimal values like "0.9" are not misread as 9.
// Returns 70 (a safe mid-range default) for empty or structurally invalid strings.
fn parse_float_x100(s: String) -> Int {
if str_eq(s, "") { return 70 }
if !str_contains(s, ".") {
// Integer input: treat as a whole number * 100 (e.g. "1" -> 100)
let whole: Int = str_to_int(s)
return whole * 100
}
// Split at the dot. str_slice(s, 0, dot_pos) gives left, rest gives right.
let dot_pos: Int = str_index_of(s, ".")
let left: String = str_slice(s, 0, dot_pos)
let right_raw: String = str_slice(s, dot_pos + 1, str_len(s))
// Normalise right side to exactly 2 decimal digits.
let right: String = if str_eq(right_raw, "") {
"00"
} else {
if str_len(right_raw) == 1 {
right_raw + "0"
} else {
if str_len(right_raw) >= 3 {
str_slice(right_raw, 0, 2)
} else {
right_raw
}
}
}
let left_val: Int = if str_eq(left, "") { 0 } else { str_to_int(left) }
let right_val: Int = str_to_int(right)
return left_val * 100 + right_val
}
// 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,
@@ -85,13 +50,13 @@ fn engram_score_node(node_json: String) -> Int {
let tier_str: String = json_get(node_json, "tier")
// Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults.
// parse_float_x100 handles 1- and 2-decimal floats correctly ("0.9" -> 90, "0.85" -> 85).
// Parse as floats via * 100 integer arithmetic (el has no float math).
let salience_100: Int = if !engram_numeric_valid(salience_str) { 70 } else {
let s: Int = parse_float_x100(salience_str)
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
}
let importance_100: Int = if !engram_numeric_valid(importance_str) { 70 } else {
let v: Int = parse_float_x100(importance_str)
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
}
@@ -132,7 +97,7 @@ fn engram_render_node(node_json: String) -> String {
}
let salience_str: String = json_get(node_json, "salience")
let sal_100: Int = if str_eq(salience_str, "") { 0 } else {
let s: Int = parse_float_x100(salience_str)
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
}
let salience_hint: String = if str_eq(salience_str, "") { "" } else {
@@ -212,8 +177,8 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
while ci < total {
let node: String = json_array_get(nodes_json, ci)
let score: Int = engram_score_node(node)
// Threshold 25: sal=0.5 * imp=0.5 * recency=1.0 -> 50*50*100/10000 = 25.
let above_thresh: Bool = score >= 25
// Threshold: includes moderately-relevant older nodes (score >= 15).
let above_thresh: Bool = score >= 15
let idx_marker: String = "|" + int_to_str(ci) + "|"
let already_picked: Bool = str_contains(selected_indices, idx_marker)
let is_better: Bool = score > best_score && above_thresh && !already_picked
@@ -253,43 +218,40 @@ fn chat_default_model() -> String {
// 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")
let updated_str: String = json_get(node_json, "updated_at")
let tier_str: String = json_get(node_json, "tier")
// parse_float_x100 handles 1- and 2-decimal floats correctly ("0.9" -> 90, "0.85" -> 85).
// Default 70 when field is absent; clamp to 0-100 range.
// 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 = parse_float_x100(salience_str)
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
}
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
let v: Int = parse_float_x100(importance_str)
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 }
}
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
return salience_100 * importance_100 * recency_100 / 10000
}
// engram_compile_ranked — build a context string from a JSON array of node objects,
// ordered best-first by score. Only nodes above threshold=25 are included.
// With corrected float parsing: sal=0.5 * imp=0.5 at max recency (100) scores exactly 25,
// so threshold=25 admits all nodes with at least moderate salience and importance while
// cutting near-zero noise. Lower values were masking the bug; 25 is correct post-fix.
// ordered best-first by score. Only nodes above threshold=15 are included.
// With corrected parsing: sal=0.5 * imp=0.5 at max recency scores 25; threshold 15
// gives headroom for moderately-relevant older nodes while filtering near-zero noise.
// Returns at most max_nodes entries. max_nodes must not exceed 20 (sentinel limit).
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
if str_eq(nodes_json, "") { return "" }
@@ -306,8 +268,9 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
while ci < total {
let node: String = json_array_get(nodes_json, ci)
let score: Int = engram_score_node(node)
// Threshold 25: sal=0.5 * imp=0.5 * recency=1.0 -> 50*50*100/10000 = 25.
let above_thresh: Bool = score >= 25
// Threshold lowered from 25 to 15: includes moderately-relevant older nodes.
// A 3-week-old node with salience 0.6 and importance 0.6 scores ~18 — was dropped, now included.
let above_thresh: Bool = score >= 15
// Check this index wasn't already selected (sentinel: look for idx marker)
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
let already_picked: Bool = str_contains(selected, idx_marker)
@@ -596,7 +559,7 @@ fn engram_compile(intent: String) -> String {
} else { "" }
} else { "" }
// Affective context: always include the most recent high-emotion memory within 14 days.
// Affective context: always include the most recent high-emotion memory within 72h.
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
let now_ts: Int = time_now()
@@ -717,6 +680,17 @@ fn build_system_prompt(ctx: String, chat_mode: Bool) -> String {
"\n\n[IDENTITY GRAPH — who you are, loaded from your engram]\n" + id_ctx
}
// soul_affective_context is loaded at boot by load_identity_context() with BellEvent/
// PositiveEvent nodes from the last 7 days. Surfaced here so the LLM sees historical
// emotional patterns from prior sessions at every turn.
// Issue 1 fix: declare affective_boot_block before it is referenced in the return.
let boot_aff_ctx: String = state_get("soul_affective_context")
let affective_boot_block: String = if str_eq(boot_aff_ctx, "") {
""
} else {
"\n\n[CROSS-SESSION EMOTIONAL CONTEXT — from prior sessions]\n" + boot_aff_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.
@@ -925,17 +899,10 @@ fn handle_chat(body: String) -> String {
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) }
// Issue 8 fix: use semantic continuation detection instead of brittle 50-char threshold.
let is_continuation: Bool = engram_is_continuation(message, hist_len)
let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" }
let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" }
// Thread snip extended 150->250 chars for better pronoun resolution context.
let thread_snip: String = if str_len(last_content) > 250 { str_slice(last_content, 0, 250) } else { last_content }
let activation_seed: String = if !str_eq(thread_snip, "") {
thread_snip + " " + message
} else {
message
}
// Build activation seed via build_activation_seed which anchors to the most recent
// USER turn (not the last entry regardless of role) and blends multi-turn context.
// Fixes Issues 4 (dead code) and 9 (role-blind last_entry access).
let activation_seed: String = build_activation_seed(message, stored_hist, hist_len)
// 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.
@@ -1003,7 +970,6 @@ fn handle_chat(body: String) -> String {
// Issue 9 fix: add project-specific and session-summary searches to session preload.
// Old hardcoded "user profile" and "in_progress active project" miss project-specific
// nodes stored under names like "Prism" unless those exact words appear in content.
// Dedup fix: skip any node whose ID already appeared in engram_compile's output.
let session_preload: String = if hist_len == 0 {
let profile_nodes: String = engram_search_json("user profile identity preferences", 5)
let work_nodes: String = engram_search_json("in_progress active project work", 5)
@@ -1029,24 +995,21 @@ fn handle_chat(body: String) -> String {
let bullets: String = ""
let bullets = if pn > 0 {
let n0: String = json_array_get(profile_nodes, 0)
let n0_id: String = json_get(n0, "id")
let c0: String = json_get(n0, "content")
let s0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 }
if str_eq(s0, "") || id_in_seen(n0_id, seen_ids) { bullets } else { "- " + s0 }
if str_eq(s0, "") { bullets } else { "- " + s0 }
} else { bullets }
let bullets = if pn > 1 {
let n1: String = json_array_get(profile_nodes, 1)
let n1_id: String = json_get(n1, "id")
let c1: String = json_get(n1, "content")
let s1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 }
if str_eq(s1, "") || id_in_seen(n1_id, seen_ids) { bullets } else { bullets + "\n- " + s1 }
if str_eq(s1, "") { bullets } else { bullets + "\n- " + s1 }
} else { bullets }
let bullets = if pn > 2 {
let n2: String = json_array_get(profile_nodes, 2)
let n2_id: String = json_get(n2, "id")
let c2: String = json_get(n2, "content")
let s2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 }
if str_eq(s2, "") || id_in_seen(n2_id, seen_ids) { bullets } else { bullets + "\n- " + s2 }
if str_eq(s2, "") { bullets } else { bullets + "\n- " + s2 }
} else { bullets }
bullets
} else { "" }
@@ -1056,17 +1019,15 @@ fn handle_chat(body: String) -> String {
let wb: String = ""
let wb = if wn > 0 {
let w0: String = json_array_get(work_nodes, 0)
let w0_id: String = json_get(w0, "id")
let wc0: String = json_get(w0, "content")
let ws0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 }
if str_eq(ws0, "") || id_in_seen(w0_id, seen_ids) { wb } else { "- " + ws0 }
if str_eq(ws0, "") { wb } else { "- " + ws0 }
} else { wb }
let wb = if wn > 1 {
let w1: String = json_array_get(work_nodes, 1)
let w1_id: String = json_get(w1, "id")
let wc1: String = json_get(w1, "content")
let ws1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 }
if str_eq(ws1, "") || id_in_seen(w1_id, seen_ids) { wb } else { wb + "\n- " + ws1 }
if str_eq(ws1, "") { wb } else { wb + "\n- " + ws1 }
} else { wb }
wb
} else { "" }
@@ -1076,27 +1037,24 @@ fn handle_chat(body: String) -> String {
let pb: String = ""
let pb = if prn > 0 {
let pr0: String = json_array_get(project_nodes, 0)
let pr0_id: String = json_get(pr0, "id")
let prc0: String = json_get(pr0, "content")
let ps0: String = if str_len(prc0) > 120 { str_slice(prc0, 0, 120) } else { prc0 }
if str_eq(ps0, "") || id_in_seen(pr0_id, seen_ids) { pb } else { "- " + ps0 }
if str_eq(ps0, "") { pb } else { "- " + ps0 }
} else { pb }
let pb = if prn > 1 {
let pr1: String = json_array_get(project_nodes, 1)
let pr1_id: String = json_get(pr1, "id")
let prc1: String = json_get(pr1, "content")
let ps1: String = if str_len(prc1) > 120 { str_slice(prc1, 0, 120) } else { prc1 }
if str_eq(ps1, "") || id_in_seen(pr1_id, seen_ids) { pb } else { pb + "\n- " + ps1 }
if str_eq(ps1, "") { pb } else { pb + "\n- " + ps1 }
} else { pb }
pb
} else { "" }
let summary_bullet: String = if summary_ok {
let sn0: String = json_array_get(summary_nodes, 0)
let sn0_id: String = json_get(sn0, "id")
let sc0: String = json_get(sn0, "content")
let ss0: String = if str_len(sc0) > 200 { str_slice(sc0, 0, 200) } else { sc0 }
if str_eq(ss0, "") || id_in_seen(sn0_id, seen_ids) { "" } else { "- " + ss0 }
if str_eq(ss0, "") { "" } else { "- " + ss0 }
} else { "" }
let hp: Bool = !str_eq(profile_bullets, "")
@@ -1170,29 +1128,12 @@ fn handle_chat(body: String) -> String {
state_set("conv_history", final_hist)
conv_history_persist(final_hist)
// Session-end summary hook: write a dated SessionSummary node once per boot when
// the conversation reaches >= 5 user turns (10 hist entries = 5 user+assistant pairs).
// Uses a per-boot label ("session:summary:<boot_ts>") so summaries accumulate across
// sessions instead of overwriting a single global node. A state flag prevents rewriting
// on every subsequent turn once the threshold is crossed.
let final_hist_len: Int = json_array_len(final_hist)
if final_hist_len >= 10 {
let already_wrote: String = state_get("session_summary_written")
if str_eq(already_wrote, "") {
// Derive (or create) a stable boot-scoped session id.
let boot_id: String = state_get("session_boot_id")
let boot_id = if str_eq(boot_id, "") {
let new_id: String = int_to_str(time_now())
state_set("session_boot_id", new_id)
new_id
} else { boot_id }
let sess_label: String = "session:summary:" + boot_id
let auto_sum: String = session_summary_autogenerate(final_hist)
if !str_eq(auto_sum, "") {
let discard_sum: String = session_summary_write_dated(auto_sum, sess_label)
state_set("session_summary_written", "1")
}
}
// 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)
@@ -1652,9 +1593,14 @@ fn handle_chat_agentic(body: String) -> String {
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))
// Issue 5 fix: do NOT call safety_log_bell here. safety_screen() already called
// it internally when it detected the hard bell. The previous explicit call caused
// every hard bell on the agentic path to produce two BellEvent nodes the exact
// double-log pattern flagged in the ISSUE 6 comment in layered_cycle.
// Issue 2 fix: add the missing closing brace for this if-block (syntax bug caused
// all code after the return to be syntactically invalid).
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 }
@@ -2275,32 +2221,6 @@ fn session_summary_write(summary_text: String) -> String {
return node_id
}
// session_summary_write_dated write a SessionSummary node with a caller-supplied dated label.
// Unlike session_summary_write, this does NOT delete old nodes each session accumulates its
// own node so engram_search_json("session:summary") can return multiple past sessions.
// The label must be unique per session (e.g. "session:summary:<boot_ts>").
fn session_summary_write_dated(summary_text: String, label: String) -> String {
if str_eq(summary_text, "") { return "" }
if str_eq(label, "") { 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
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
let node_id: String = engram_node_full(
content, "SessionSummary", label,
el_from_float(0.9), el_from_float(0.8), el_from_float(1.0),
"Episodic", tags
)
if str_eq(node_id, "") {
println("[chat] session_summary_write_dated: engram write failed — summary node lost (label=" + label + ")")
return ""
}
println("[chat] session_summary_write_dated: wrote SessionSummary (" + int_to_str(str_len(content)) + " chars) label=" + label + " -> " + 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.
@@ -2488,56 +2408,3 @@ fn strengthen_chat_nodes(activation_nodes: String) -> Void {
let i = i + 1
}
}
// session_summary_autogenerate build a minimal summary from conversation history without LLM.
// Extracts user message snippets (first 80 chars each, up to 5 turns).
// Called by the session-end hook when >= 5 complete turns have occurred.
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
}
// session_summary_write_dated write a SessionSummary node with a caller-supplied dated label.
// Unlike a global-label write, this does NOT delete old nodes each session accumulates its
// own node so engram_search_json("session:summary") can return multiple past sessions.
// The label must be unique per session (e.g. "session:summary:<boot_ts>").
// Uses salience 0.85/importance 0.85 (two-decimal) to avoid the single-decimal parse bug.
fn session_summary_write_dated(summary_text: String, label: String) -> String {
if str_eq(summary_text, "") { return "" }
if str_eq(label, "") { 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
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
let node_id: String = engram_node_full(
content, "SessionSummary", label,
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_dated: engram write failed — summary node lost (label=" + label + ")")
return ""
}
println("[chat] session_summary_write_dated: wrote SessionSummary (" + int_to_str(str_len(content)) + " chars) label=" + label + " -> " + node_id)
return node_id
}
+16 -4
View File
@@ -299,19 +299,31 @@ 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\"]"
}
// Returns "none" | "low" | "high".
// Issue 3 fix: normalize the message before matching — all phrases in the list are
// lowercase, and sibling functions (safety_detect_bell_level, safety_classify_hard_bell)
// both call safety_normalize() first. Without normalization, messages like "I GOT THE JOB",
// "Thrilled!", or "We Won" never match and silently return "none".
// Issue 4 fix: use json_array_get_string (matching safety_any_match / safety_count_match)
// instead of json_array_get, so phrase extraction uses the same helper everywhere.
// Issue 7 fix: emit "low" for a single-phrase match and "high" for two or more.
// Previously only "high" or "none" were possible, making the "low" branch in auto_persist
// and the "joy:low" engram tag permanently unreachable.
fn safety_detect_positive_level(message: String) -> String {
let text: String = safety_normalize(message)
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
let count: Int = 0
while i < n {
let phrase: String = json_array_get(phrases, i)
if str_contains(message, phrase) {
return "high"
}
let phrase: String = json_array_get_string(phrases, i)
let count = if str_contains(text, phrase) { count + 1 } else { count }
let i = i + 1
}
if count >= 2 { return "high" }
if count == 1 { return "low" }
return "none"
}