Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson 588ca11f57 fix(context-dedup): include scan_part and affective_part IDs in seen set
Two design bugs in the state_set placement caused the dedup seen-ID set
to be incomplete even with callsites wired up:

1. state_set("engram_compile_seen_ids") was called immediately after
   merging the main node pools, before scan_part (persona fallback) and
   affective_part (bell node) were computed. Nodes appearing only in
   those segments were never added to the seen set.

2. affective_part is a bare JSON object (bn0 from json_array_get), not
   a JSON array. Passing it to engram_extract_ids would have gotten
   json_array_len == 0 and silently skipped the affective node's ID.

Fix: move state_set to after ctx is assembled from all three segments.
Extract ids_from_merged and ids_from_scan via engram_extract_ids (both
are JSON arrays), and extract ids_from_affective via json_get(affective_part, "id")
directly since it is a bare object. Merge all three via add_to_seen
before publishing to state.
2026-06-22 14:19:14 -05:00
will.anderson 9e178d8371 fix(recall): deduplicate engram nodes by ID across activation and search passes
Thread a seen-node-ID exclusion set from engram_compile() through to
session_preload in handle_chat, preventing the same high-salience nodes
(identity, recent memories) from appearing 2-3x in the system prompt.

Changes:
- Add id_in_seen(), add_to_seen(), engram_extract_ids() helpers that
  maintain a comma-delimited seen-ID accumulator (EL has no Set type)
- In engram_compile(): after merging all topic/entity/recall pools, extract
  node IDs from merged_nodes and publish via state_set(engram_compile_seen_ids)
- In handle_chat(): read seen_ids from state after engram_compile() returns,
  then check id_in_seen() before emitting each session_preload bullet
  (profile x3, work x2, project x2, summary x1 — all 8 candidate nodes guarded)

Nodes already present in the compiled engram context are skipped in preload,
eliminating 3000-3500 token repetition on first-message turns.
2026-06-22 14:06:04 -05:00
+86 -303
View File
@@ -12,129 +12,114 @@ fn chat_default_model() -> String {
return "claude-sonnet-4-5" 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" is a valid zero.
let parsed: Int = str_to_int(no_dot)
if parsed == 0 && !str_eq(no_dot, "0") { return false }
return true
}
// parse_float_x100 parse a float string like "0.9" or "0.85" into an integer
// scaled by 100. Pads single-decimal values to two decimals before stripping the
// dot so that "0.9" -> "090" -> 90 (not 9) and "1.0" -> "100" -> 100 (not 10).
// Only two-decimal floats like "0.85" naturally produce the correct result from
// a bare str_replace(s, ".", "") single-decimal inputs require this padding step.
fn parse_float_x100(s: String) -> Int {
if str_eq(s, "") { return 0 }
let dot_pos: Int = str_index_of(s, ".")
if dot_pos < 0 {
// Integer string multiply by 100
return str_to_int(s) * 100
}
let decimal_part: String = str_slice(s, dot_pos + 1, str_len(s))
let dec_len: Int = str_len(decimal_part)
// Pad to exactly 2 decimal digits so the strip-dot result is always x100
let padded: String = if dec_len == 0 { s + "00" } else {
if dec_len == 1 { s + "0" } else { s }
}
// Now strip the dot result is the integer scaled by 100
return str_to_int(str_replace(padded, ".", ""))
}
// 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,
// 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")
// 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)
// parse_float_x100 correctly handles single-decimal floats like "0.9" -> 90. let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
let salience_100: Int = if !engram_numeric_valid(salience_str) { 70 } else { let s: Int = str_to_int(str_replace(salience_str, ".", ""))
let s: Int = parse_float_x100(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 !engram_numeric_valid(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 ranked list of nodes, best-first by score. // engram_compile_ranked build a context string from a JSON array of node objects,
// Fix (Issue #11): uses "|N|" index tracking instead of _sel_N JSON mutation, // ordered best-first by score. Only nodes above a minimum score (25 = salience 0.5 *
// which leaked sentinel fields into the node objects passed to the LLM. // importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most
// Works correctly for any input array size no sentinel cleanup needed. // max_nodes entries concatenated as JSON array text. Because el has no sort primitive,
// we do a single selection pass picking the top N by linear scan (N=10 cap).
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 "" }
if str_eq(nodes_json, "[]") { return "" } if str_eq(nodes_json, "[]") { return "" }
let total: Int = json_array_len(nodes_json) let total: Int = json_array_len(nodes_json)
if total == 0 { return "" } if total == 0 { return "" }
let selected_indices: String = ""
let selected_nodes: String = "" // Two-pass: first pass finds the top `max_nodes` by score via selection.
// We track selected node indices and their scores to avoid duplicate picks.
let selected: String = "" // comma-sep JSON snippets for chosen nodes
let selected_count: Int = 0
let pass: Int = 0 let pass: Int = 0
while pass < max_nodes && pass < total { while pass < max_nodes && pass < total {
// Find the unselected node with the highest score
let best_idx: Int = -1 let best_idx: Int = -1
let best_score: Int = -1 let best_score: Int = -1
let ci: Int = 0 let ci: Int = 0
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: includes moderately-relevant older nodes (score >= 15). // 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 let above_thresh: Bool = score >= 15
let idx_marker: String = "|" + int_to_str(ci) + "|" // Check this index wasn't already selected (sentinel: look for idx marker)
let already_picked: Bool = str_contains(selected_indices, idx_marker) let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
let already_picked: Bool = str_contains(selected, idx_marker)
let is_better: Bool = score > best_score && above_thresh && !already_picked let is_better: Bool = score > best_score && above_thresh && !already_picked
let best_score = if is_better { score } else { best_score } let best_score = if is_better { score } else { best_score }
let best_idx = if is_better { ci } else { best_idx } let best_idx = if is_better { ci } else { best_idx }
let ci = ci + 1 let ci = ci + 1
} }
// No more qualifying nodes
if best_idx < 0 { if best_idx < 0 {
let pass = total // break let pass = total // break
} else { } else {
let chosen: String = json_array_get(nodes_json, best_idx) let chosen: String = json_array_get(nodes_json, best_idx)
let sep: String = if str_eq(selected_nodes, "") { "" } else { "," } let sep: String = if str_eq(selected, "") { "" } else { "," }
let selected_nodes = selected_nodes + sep + chosen // Append the index sentinel inline so already_picked checks work
let selected_indices = selected_indices + "|" + int_to_str(best_idx) + "|" let selected = selected + sep + "{\"_sel_" + int_to_str(best_idx) + "\":1," + str_slice(chosen, 1, str_len(chosen) - 1) + "}"
let selected_count = selected_count + 1
} }
let pass = pass + 1 let pass = pass + 1
} }
if str_eq(selected_nodes, "") { return "" }
return "[" + selected_nodes + "]" if str_eq(selected, "") { return "" }
// Strip the _sel_N sentinel fields that were used for duplicate-detection bookkeeping.
// The sentinels have the form "\"_sel_N\":1," (trailing comma, space before next key).
// We injected them as the first field in each object, so the pattern is predictable.
// Because el has no regex, remove up to 10 possible sentinel variants by literal replace.
let clean: String = "[" + selected + "]"
let c0: String = str_replace(clean, "\"_sel_0\":1,", "")
let c1: String = str_replace(c0, "\"_sel_1\":1,", "")
let c2: String = str_replace(c1, "\"_sel_2\":1,", "")
let c3: String = str_replace(c2, "\"_sel_3\":1,", "")
let c4: String = str_replace(c3, "\"_sel_4\":1,", "")
let c5: String = str_replace(c4, "\"_sel_5\":1,", "")
let c6: String = str_replace(c5, "\"_sel_6\":1,", "")
let c7: String = str_replace(c6, "\"_sel_7\":1,", "")
let c8: String = str_replace(c7, "\"_sel_8\":1,", "")
let c9: String = str_replace(c8, "\"_sel_9\":1,", "")
let c10: String = str_replace(c9, "\"_sel_10\":1,", "")
let c11: String = str_replace(c10, "\"_sel_11\":1,", "")
let c12: String = str_replace(c11, "\"_sel_12\":1,", "")
let c13: String = str_replace(c12, "\"_sel_13\":1,", "")
let c14: String = str_replace(c13, "\"_sel_14\":1,", "")
return c14
} }
// engram_split_topics split message into sub-queries on explicit conjunctions. // engram_split_topics split message into sub-queries on explicit conjunctions.
@@ -405,11 +390,11 @@ 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()
let cutoff_ts: Int = now_ts - 1209600 let cutoff_ts: Int = now_ts - 259200
let recent_bell: String = if bell_ok { let recent_bell: String = if bell_ok {
let bn0: String = json_array_get(bell_nodes, 0) let bn0: String = json_array_get(bell_nodes, 0)
let bn_content: String = json_get(bn0, "content") let bn_content: String = json_get(bn0, "content")
@@ -424,35 +409,10 @@ fn engram_compile(intent: String) -> String {
let ca: String = json_get(bn0, "created_at") let ca: String = json_get(bn0, "created_at")
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca } 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 str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
let bn_ts: Int = if !engram_numeric_valid(bn_ts_raw) { 0 } else { str_to_int(bn_ts_raw) }
if bn_ts > cutoff_ts { bn0 } else { "" } if bn_ts > cutoff_ts { bn0 } else { "" }
} else { "" } } else { "" }
// Positive emotion context: check for recent joy/success moments within 14 days. let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" }
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 has_main: Bool = !str_eq(merged_nodes, "") && !str_eq(merged_nodes, "[]") let has_main: Bool = !str_eq(merged_nodes, "") && !str_eq(merged_nodes, "[]")
let main_part: String = if has_main { merged_nodes } else { scan_part } let main_part: String = if has_main { merged_nodes } else { scan_part }
@@ -641,58 +601,29 @@ fn clean_llm_response(s: String) -> String {
} }
// conv_history_persist save conversation history to engram for cross-restart continuity. // 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). // Stores as a Conversation node. Overwrites by using consistent label "conv:history".
// Q3/Q6 fix: added partial-write guard and failure logging.
fn conv_history_persist(hist: String) -> Void { fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "") { return "" } if str_eq(hist, "") { return "" }
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. let ts: Int = time_now()
// A truncated write starting with '[' but missing ']' would overwrite a good node.
if !str_starts_with(hist, "[") { return "" }
if !str_contains(hist, "]") { return "" }
let tags: String = "[\"conv-history\",\"persistent\"]" let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full( let discard: String = engram_node_full(
hist, "Conversation", "conv:history", hist, "Conversation", "conv:history",
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9), el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags "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. // 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 // Returns the most recent "conv:history" node content, or "" if none found.
// callers to distinguish genuine first-turn from a load failure.
fn conv_history_load() -> String { 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_contains(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) let results: String = engram_search_json("conv:history", 3)
if str_eq(results, "") { if str_eq(results, "") { return "" }
// 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 node: String = json_array_get(results, 0)
let content: String = json_get(node, "content") let content: String = json_get(node, "content")
// Partial-write guard: require both '[' prefix AND ']' presence. // Validate it looks like a JSON array
if !str_starts_with(content, "[") || !str_contains(content, "]") { if !str_starts_with(content, "[") { return "" }
println("[chat] conv_history_load: vector search result content invalid — treating as first turn")
state_set("conv_history_load_failed", "1")
return ""
}
return content return content
} }
@@ -707,7 +638,6 @@ fn handle_chat(body: String) -> String {
// /api/chat requests without session_id race on this read-append-write. // /api/chat requests without session_id race on this read-append-write.
let state_hist: String = state_get("conv_history") let state_hist: String = state_get("conv_history")
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist } 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) } 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. // Issue 8 fix: use semantic continuation detection instead of brittle 50-char threshold.
@@ -722,65 +652,24 @@ fn handle_chat(body: String) -> String {
message message
} }
// Cross-session affective context: check engram for recent distress/positive signals // Cross-session affective context: on session start (no history yet), check engram
// within 72h and prepend a care directive if found. Runs every turn so the directive // for recent distress signals within 72h and prepend a care directive if found.
// is present throughout the session, not just on turn 1. let affective_prefix: String = if hist_len == 0 {
let affective_prefix: String = { let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
// Runs every turn. Uses correct BellEvent/PositiveEvent tags. let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
let aff_now_ts: Int = time_now() let now_ts: Int = time_now()
let aff_cutoff: Int = aff_now_ts - 259200 let cutoff: Int = now_ts - 259200
let boot_aff: String = state_get("soul_affective_context") let found_recent: Bool = if has_nodes {
let has_boot_aff: Bool = !str_eq(boot_aff, "") let dn0: String = json_array_get(distress_nodes, 0)
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3) let ts0_raw: String = json_get(dn0, "created_at")
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]") let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
let found_recent_dist: Bool = if has_boot_aff { let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
true ts0 > cutoff
} 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 } } else { false }
if found_recent_dist { if found_recent {
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n" "[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
} else { } else { "" }
if found_recent_pos { } else { "" }
"[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 ctx: String = engram_compile(activation_seed)
// Read IDs published by engram_compile so session_preload can skip duplicate nodes. // Read IDs published by engram_compile so session_preload can skip duplicate nodes.
@@ -895,28 +784,8 @@ fn handle_chat(body: String) -> String {
preload preload
} else { "" } } else { "" }
// Issue #6 fix: render conversation history as readable dialogue instead of raw JSON.
// Injecting a raw JSON array into a natural-language system prompt degrades LLM
// comprehension. Each turn is rendered as "User: .../Assistant: ..." with 400-char
// truncation so the prompt stays token-efficient while remaining human-readable.
let rendered_hist: String = if hist_len > 0 {
let rh_total: Int = json_array_len(stored_hist)
let rh_out: String = ""
let rh_i: Int = 0
while rh_i < rh_total {
let rh_entry: String = json_array_get(stored_hist, rh_i)
let rh_role: String = json_get(rh_entry, "role")
let rh_content: String = json_get(rh_entry, "content")
let rh_label: String = if str_eq(rh_role, "user") { "User" } else { "Assistant" }
let rh_snip: String = if str_len(rh_content) > 400 { str_slice(rh_content, 0, 400) + "..." } else { rh_content }
let rh_line: String = rh_label + ": " + rh_snip
let rh_out = if str_eq(rh_out, "") { rh_line } else { rh_out + "\n" + rh_line }
let rh_i = rh_i + 1
}
rh_out
} else { "" }
let full_system: String = if hist_len > 0 { let full_system: String = if hist_len > 0 {
system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + rendered_hist system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
} else { } else {
system + session_preload system + session_preload
} }
@@ -952,43 +821,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
// 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")
}
}
}
let activation_nodes: String = engram_activate_json(message, 2) let activation_nodes: String = engram_activate_json(message, 2)
let act_ok: Bool = !str_eq(activation_nodes, "") && !str_eq(activation_nodes, "[]") let act_ok: Bool = !str_eq(activation_nodes, "") && !str_eq(activation_nodes, "[]")
let act_out: String = if act_ok { activation_nodes } else { "[]" } let act_out: String = if act_ok { activation_nodes } else { "[]" }
strengthen_chat_nodes(act_out) strengthen_chat_nodes(act_out)
// Q3 fix: surface history load failure in the response envelope so callers can return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + "}"
// 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 + "}"
} }
fn handle_see(body: String) -> String { fn handle_see(body: String) -> String {
@@ -1428,9 +1266,7 @@ fn handle_chat_agentic(body: String) -> String {
// L1 safety screen agentic path must pass the same gate as layered_cycle. // L1 safety screen agentic path must pass the same gate as layered_cycle.
// Hard bell: return the crisis response immediately, do not enter the agentic loop. // Hard bell: return the crisis response immediately, do not enter the agentic loop.
// Fix(issue #9): "conversation_history" key was never written; history lives under "conv_history". let history: String = state_get("conversation_history")
// Old key caused history-amplification in safety_screen to always receive "" on agentic path.
let history: String = state_get("conv_history")
let screen_result: String = safety_screen(message, history) let screen_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") {
@@ -2084,56 +1920,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
}