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 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 // engram_score_node compute a recency x relevance score for a single engram
// node JSON object. Higher is better. Score = salience * importance * recency_factor. // node JSON object. Higher is better. Score = salience * importance * recency_factor.
// recency_factor decays linearly over 30 days: nodes updated today score 1.0, // 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") let tier_str: String = json_get(node_json, "tier")
// Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults. // 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 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 } } 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 !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 } } 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 salience_str: String = json_get(node_json, "salience")
let sal_100: Int = if str_eq(salience_str, "") { 0 } else { 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 } } if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
} }
let salience_hint: String = if str_eq(salience_str, "") { "" } else { 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 { while ci < total {
let node: String = json_array_get(nodes_json, ci) let node: String = json_array_get(nodes_json, ci)
let score: Int = engram_score_node(node) let score: Int = engram_score_node(node)
// Threshold 25: sal=0.5 * imp=0.5 * recency=1.0 -> 50*50*100/10000 = 25. // Threshold: includes moderately-relevant older nodes (score >= 15).
let above_thresh: Bool = score >= 25 let above_thresh: Bool = score >= 15
let idx_marker: String = "|" + int_to_str(ci) + "|" let idx_marker: String = "|" + int_to_str(ci) + "|"
let already_picked: Bool = str_contains(selected_indices, idx_marker) let already_picked: Bool = str_contains(selected_indices, idx_marker)
let is_better: Bool = score > best_score && above_thresh && !already_picked 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. // 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 // 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. // 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 { fn engram_score_node(node_json: String) -> Int {
let salience_str: String = json_get(node_json, "salience") let salience_str: String = json_get(node_json, "salience")
let importance_str: String = json_get(node_json, "importance") let importance_str: String = json_get(node_json, "importance")
let created_str: String = json_get(node_json, "created_at") 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). // Parse as floats via * 100 integer arithmetic (el has no float math)
// Default 70 when field is absent; clamp to 0-100 range.
let salience_100: Int = if str_eq(salience_str, "") { 70 } else { 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 } } if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
} }
let importance_100: Int = if str_eq(importance_str, "") { 70 } else { 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 } } 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 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 created_ts: Int = str_to_int(created_str)
let age_secs: Int = now_ts - created_ts let age_secs: Int = now_ts - created_ts
// Q1 fix: guard against clock skew / future timestamps — treat as fresh. let age_days: Int = age_secs / 86400
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) } let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
if decay < 10 { 10 } else { decay } if decay < 10 { 10 } else { decay }
} }
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
return salience_100 * importance_100 * recency_100 / 10000 return salience_100 * importance_100 * recency_100 / 10000
} }
// engram_compile_ranked — build a context string from a JSON array of node objects, // 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. // ordered best-first by score. Only nodes above threshold=15 are included.
// With corrected float parsing: sal=0.5 * imp=0.5 at max recency (100) scores exactly 25, // With corrected parsing: sal=0.5 * imp=0.5 at max recency scores 25; threshold 15
// so threshold=25 admits all nodes with at least moderate salience and importance while // gives headroom for moderately-relevant older nodes while filtering near-zero noise.
// cutting near-zero noise. Lower values were masking the bug; 25 is correct post-fix.
// Returns at most max_nodes entries. max_nodes must not exceed 20 (sentinel limit). // 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 { fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
if str_eq(nodes_json, "") { return "" } if str_eq(nodes_json, "") { return "" }
@@ -306,8 +268,9 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
while ci < total { while ci < total {
let node: String = json_array_get(nodes_json, ci) let node: String = json_array_get(nodes_json, ci)
let score: Int = engram_score_node(node) let score: Int = engram_score_node(node)
// Threshold 25: sal=0.5 * imp=0.5 * recency=1.0 -> 50*50*100/10000 = 25. // Threshold lowered from 25 to 15: includes moderately-relevant older nodes.
let above_thresh: Bool = score >= 25 // 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) // Check this index wasn't already selected (sentinel: look for idx marker)
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\"" let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
let already_picked: Bool = str_contains(selected, idx_marker) let already_picked: Bool = str_contains(selected, idx_marker)
@@ -596,7 +559,7 @@ fn engram_compile(intent: String) -> String {
} else { "" } } else { "" }
} 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_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]") let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
let now_ts: Int = time_now() 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 "\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 // 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") // 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. // 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_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) } 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. // Build activation seed via build_activation_seed which anchors to the most recent
let is_continuation: Bool = engram_is_continuation(message, hist_len) // USER turn (not the last entry regardless of role) and blends multi-turn context.
let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" } // Fixes Issues 4 (dead code) and 9 (role-blind last_entry access).
let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" } let activation_seed: String = build_activation_seed(message, stored_hist, hist_len)
// 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
}
// Cross-session affective context: on session start (no history yet), check engram // 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. // 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. // 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 // 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. // 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 session_preload: String = if hist_len == 0 {
let profile_nodes: String = engram_search_json("user profile identity preferences", 5) 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) 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: String = ""
let bullets = if pn > 0 { let bullets = if pn > 0 {
let n0: String = json_array_get(profile_nodes, 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 c0: String = json_get(n0, "content")
let s0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 } 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 } } else { bullets }
let bullets = if pn > 1 { let bullets = if pn > 1 {
let n1: String = json_array_get(profile_nodes, 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 c1: String = json_get(n1, "content")
let s1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 } 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 } } else { bullets }
let bullets = if pn > 2 { let bullets = if pn > 2 {
let n2: String = json_array_get(profile_nodes, 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 c2: String = json_get(n2, "content")
let s2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 } 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 } } else { bullets }
bullets bullets
} else { "" } } else { "" }
@@ -1056,17 +1019,15 @@ fn handle_chat(body: String) -> String {
let wb: String = "" let wb: String = ""
let wb = if wn > 0 { let wb = if wn > 0 {
let w0: String = json_array_get(work_nodes, 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 wc0: String = json_get(w0, "content")
let ws0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 } 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 } } else { wb }
let wb = if wn > 1 { let wb = if wn > 1 {
let w1: String = json_array_get(work_nodes, 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 wc1: String = json_get(w1, "content")
let ws1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 } 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 } } else { wb }
wb wb
} else { "" } } else { "" }
@@ -1076,27 +1037,24 @@ fn handle_chat(body: String) -> String {
let pb: String = "" let pb: String = ""
let pb = if prn > 0 { let pb = if prn > 0 {
let pr0: String = json_array_get(project_nodes, 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 prc0: String = json_get(pr0, "content")
let ps0: String = if str_len(prc0) > 120 { str_slice(prc0, 0, 120) } else { prc0 } 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 } } else { pb }
let pb = if prn > 1 { let pb = if prn > 1 {
let pr1: String = json_array_get(project_nodes, 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 prc1: String = json_get(pr1, "content")
let ps1: String = if str_len(prc1) > 120 { str_slice(prc1, 0, 120) } else { prc1 } 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 } } else { pb }
pb pb
} else { "" } } else { "" }
let summary_bullet: String = if summary_ok { let summary_bullet: String = if summary_ok {
let sn0: String = json_array_get(summary_nodes, 0) 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 sc0: String = json_get(sn0, "content")
let ss0: String = if str_len(sc0) > 200 { str_slice(sc0, 0, 200) } else { sc0 } 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 { "" } } else { "" }
let hp: Bool = !str_eq(profile_bullets, "") let hp: Bool = !str_eq(profile_bullets, "")
@@ -1170,29 +1128,12 @@ fn handle_chat(body: String) -> String {
state_set("conv_history", final_hist) state_set("conv_history", final_hist)
conv_history_persist(final_hist) conv_history_persist(final_hist)
// Session-end summary hook: write a dated SessionSummary node once per boot when // Automatic session-end summary: write/overwrite the SessionSummary node on each turn
// the conversation reaches >= 5 user turns (10 hist entries = 5 user+assistant pairs). // so process restarts always have a continuity snapshot (no shutdown hook needed).
// Uses a per-boot label ("session:summary:<boot_ts>") so summaries accumulate across // Uses autogenerate (no LLM) so it is cheap the node is overwritten not appended.
// sessions instead of overwriting a single global node. A state flag prevents rewriting let auto_sum: String = session_summary_autogenerate(final_hist)
// on every subsequent turn once the threshold is crossed. if !str_eq(auto_sum, "") {
let final_hist_len: Int = json_array_len(final_hist) let discard_sum: String = session_summary_write(auto_sum)
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")
}
}
} }
let activation_nodes: String = engram_activate_json(message, 2) 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_result: String = safety_screen(message, history)
let screen_action: String = json_get(screen_result, "action") let screen_action: String = json_get(screen_result, "action")
if str_eq(screen_action, "hard_bell") { 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\":[]}" return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
}
let req_model: String = json_get(body, "model") let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_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 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. // session_summary_autogenerate build a minimal summary from conversation history without LLM.
// Extracts user message snippets (first 80 chars each, up to 5 turns). // 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. // 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 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\"]" 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 { fn safety_detect_positive_level(message: String) -> String {
let text: String = safety_normalize(message)
let phrases: String = safety_positive_phrases() let phrases: String = safety_positive_phrases()
let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]") let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]")
if !phrases_ok { return "none" } if !phrases_ok { return "none" }
let n: Int = json_array_len(phrases) let n: Int = json_array_len(phrases)
let i: Int = 0 let i: Int = 0
let count: Int = 0
while i < n { while i < n {
let phrase: String = json_array_get(phrases, i) let phrase: String = json_array_get_string(phrases, i)
if str_contains(message, phrase) { let count = if str_contains(text, phrase) { count + 1 } else { count }
return "high"
}
let i = i + 1 let i = i + 1
} }
if count >= 2 { return "high" }
if count == 1 { return "low" }
return "none" return "none"
} }