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
+35 -112
View File
@@ -380,11 +380,6 @@ fn engram_compile(intent: String) -> String {
let merged: String = engram_nodes_merge(merged, recall_boost)
let merged_nodes: String = merged
// Dedup fix: publish seen node IDs so downstream callers (session_preload, affective_prefix)
// can skip nodes already present here. EL has no tuple returns so we use state as out-param.
let compile_seen_ids: String = engram_extract_ids(merged_nodes)
state_set("engram_compile_seen_ids", compile_seen_ids)
// Fallback: when all searches return nothing, fetch persona nodes.
let scan_part: String = if str_eq(merged_nodes, "") || str_eq(merged_nodes, "[]") {
let persona_fallback: String = engram_search_json("soul:persona Persona identity", 5)
@@ -424,6 +419,18 @@ fn engram_compile(intent: String) -> String {
let sep_ma: String = if !str_eq(main_part, "") && !str_eq(affective_part, "") { "\n" } else { "" }
let ctx: String = main_part + sep_ma + affective_part
// Dedup fix: publish seen node IDs so downstream callers (session_preload) can skip
// nodes already present in the compiled context. Must be computed after scan_part and
// affective_part are resolved so all three segments are represented in the seen set.
// EL has no tuple returns so we use state as an out-param.
// scan_part is a JSON array extract with engram_extract_ids.
// affective_part is a bare JSON object (bn0), not an array extract its id directly.
let ids_from_merged: String = engram_extract_ids(merged_nodes)
let ids_from_scan: String = engram_extract_ids(scan_part)
let ids_from_affective: String = json_get(affective_part, "id")
let compile_seen_ids: String = add_to_seen(add_to_seen(ids_from_merged, ids_from_scan), ids_from_affective)
state_set("engram_compile_seen_ids", compile_seen_ids)
if str_eq(ctx, "") { return "" }
// Issue 7 fix: safe JSON truncation find last closing brace before budget cap.
@@ -665,11 +672,15 @@ fn handle_chat(body: String) -> String {
} else { "" }
let ctx: String = engram_compile(activation_seed)
// Read IDs published by engram_compile so session_preload can skip duplicate nodes.
// EL has no multiple return values; engram_compile writes its seen set to state.
let seen_ids: String = state_get("engram_compile_seen_ids")
let system: String = affective_prefix + build_system_prompt(ctx)
// 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)
@@ -686,21 +697,24 @@ 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, "") { bullets } else { "- " + s0 }
if str_eq(s0, "") || id_in_seen(n0_id, seen_ids) { 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, "") { bullets } else { bullets + "\n- " + s1 }
if str_eq(s1, "") || id_in_seen(n1_id, seen_ids) { 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, "") { bullets } else { bullets + "\n- " + s2 }
if str_eq(s2, "") || id_in_seen(n2_id, seen_ids) { bullets } else { bullets + "\n- " + s2 }
} else { bullets }
bullets
} else { "" }
@@ -710,15 +724,17 @@ 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, "") { wb } else { "- " + ws0 }
if str_eq(ws0, "") || id_in_seen(w0_id, seen_ids) { 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, "") { wb } else { wb + "\n- " + ws1 }
if str_eq(ws1, "") || id_in_seen(w1_id, seen_ids) { wb } else { wb + "\n- " + ws1 }
} else { wb }
wb
} else { "" }
@@ -728,54 +744,38 @@ 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, "") { pb } else { "- " + ps0 }
if str_eq(ps0, "") || id_in_seen(pr0_id, seen_ids) { 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, "") { pb } else { pb + "\n- " + ps1 }
if str_eq(ps1, "") || id_in_seen(pr1_id, seen_ids) { pb } else { pb + "\n- " + ps1 }
} else { pb }
pb
} else { "" }
// Session summary recall: show up to 3 previous session summaries so the soul
// knows what was discussed in recent past conversations.
let summary_bullets: String = if summary_ok {
let sn_total: Int = json_array_len(summary_nodes)
let sb: String = ""
let sb = if sn_total > 0 {
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, "") { sb } else { "- " + ss0 }
} else { sb }
let sb = if sn_total > 1 {
let sn1: String = json_array_get(summary_nodes, 1)
let sc1: String = json_get(sn1, "content")
let ss1: String = if str_len(sc1) > 200 { str_slice(sc1, 0, 200) } else { sc1 }
if str_eq(ss1, "") { sb } else { sb + "\n- " + ss1 }
} else { sb }
let sb = if sn_total > 2 {
let sn2: String = json_array_get(summary_nodes, 2)
let sc2: String = json_get(sn2, "content")
let ss2: String = if str_len(sc2) > 200 { str_slice(sc2, 0, 200) } else { sc2 }
if str_eq(ss2, "") { sb } else { sb + "\n- " + ss2 }
} else { sb }
sb
if str_eq(ss0, "") || id_in_seen(sn0_id, seen_ids) { "" } else { "- " + ss0 }
} else { "" }
let hp: Bool = !str_eq(profile_bullets, "")
let hw: Bool = !str_eq(work_bullets, "")
let hpr: Bool = !str_eq(project_bullets, "")
let hs: Bool = !str_eq(summary_bullets, "")
let hs: Bool = !str_eq(summary_bullet, "")
let preload: String = if hp || hw || hpr || hs {
let sec_p: String = if hp { "[USER CONTEXT — from memory]\n" + profile_bullets } else { "" }
let sec_w: String = if hw { "[ACTIVE WORK — from memory]\n" + work_bullets } else { "" }
let sec_pr: String = if hpr { "[PROJECTS — from memory]\n" + project_bullets } else { "" }
let sec_s: String = if hs { "[PREVIOUS SESSIONS]\n" + summary_bullets } else { "" }
let sec_s: String = if hs { "[PREVIOUS SESSION — from memory]\n" + summary_bullet } else { "" }
let sep1: String = if hp && (hw || hpr || hs) { "\n\n" } else { "" }
let sep2: String = if hw && (hpr || hs) { "\n\n" } else { "" }
let sep3: String = if hpr && hs { "\n\n" } else { "" }
@@ -821,31 +821,6 @@ 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")
}
}
}
let activation_nodes: String = engram_activate_json(message, 2)
let act_ok: Bool = !str_eq(activation_nodes, "") && !str_eq(activation_nodes, "[]")
let act_out: String = if act_ok { activation_nodes } else { "[]" }
@@ -1945,55 +1920,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>").
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
}