|
|
|
@@ -347,11 +347,26 @@ fn clean_llm_response(s: String) -> String {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// conv_history_persist — save conversation history to engram for cross-restart continuity.
|
|
|
|
|
// Stores as a Conversation node. Overwrites by using consistent label "conv:history".
|
|
|
|
|
// Delete-before-write under label "conv:history" prevents unbounded node accumulation (issue #11).
|
|
|
|
|
fn conv_history_persist(hist: String) -> Void {
|
|
|
|
|
if str_eq(hist, "") { return "" }
|
|
|
|
|
if str_eq(hist, "[]") { return "" }
|
|
|
|
|
let ts: Int = time_now()
|
|
|
|
|
// Delete any existing conv:history nodes before writing to avoid accumulation.
|
|
|
|
|
let old_hist_results: String = engram_search_json("conv:history", 3)
|
|
|
|
|
let old_hist_ok: Bool = !str_eq(old_hist_results, "") && !str_eq(old_hist_results, "[]")
|
|
|
|
|
if old_hist_ok {
|
|
|
|
|
let ohr_total: Int = json_array_len(old_hist_results)
|
|
|
|
|
let ohr_i: Int = 0
|
|
|
|
|
while ohr_i < ohr_total {
|
|
|
|
|
let ohr_node: String = json_array_get(old_hist_results, ohr_i)
|
|
|
|
|
let ohr_label: String = json_get(ohr_node, "label")
|
|
|
|
|
let ohr_id: String = json_get(ohr_node, "id")
|
|
|
|
|
if str_eq(ohr_label, "conv:history") && !str_eq(ohr_id, "") {
|
|
|
|
|
engram_forget(ohr_id)
|
|
|
|
|
}
|
|
|
|
|
let ohr_i = ohr_i + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let tags: String = "[\"conv-history\",\"persistent\"]"
|
|
|
|
|
let discard: String = engram_node_full(
|
|
|
|
|
hist, "Conversation", "conv:history",
|
|
|
|
@@ -400,18 +415,25 @@ fn handle_chat(body: String) -> String {
|
|
|
|
|
|
|
|
|
|
// Cross-session affective context: on session start (no history yet), check engram
|
|
|
|
|
// for recent distress signals within 72h and prepend a care directive if found.
|
|
|
|
|
// Fixes issue #6: soul_affective_context is pre-loaded at boot — use it first to
|
|
|
|
|
// avoid a redundant engram search and to make the boot-time state key functional.
|
|
|
|
|
let affective_prefix: String = if hist_len == 0 {
|
|
|
|
|
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
|
|
|
|
|
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
|
|
|
|
|
let now_ts: Int = time_now()
|
|
|
|
|
let cutoff: Int = now_ts - 259200
|
|
|
|
|
let found_recent: Bool = if has_nodes {
|
|
|
|
|
let dn0: String = json_array_get(distress_nodes, 0)
|
|
|
|
|
let ts0_raw: String = json_get(dn0, "created_at")
|
|
|
|
|
let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
|
|
|
|
|
let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
|
|
|
|
|
ts0 > cutoff
|
|
|
|
|
} else { false }
|
|
|
|
|
let soul_aff_ctx: String = state_get("soul_affective_context")
|
|
|
|
|
let found_recent: Bool = if !str_eq(soul_aff_ctx, "") {
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
|
|
|
|
|
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
|
|
|
|
|
let now_ts: Int = time_now()
|
|
|
|
|
let cutoff: Int = now_ts - 259200
|
|
|
|
|
if has_nodes {
|
|
|
|
|
let dn0: String = json_array_get(distress_nodes, 0)
|
|
|
|
|
let ts0_raw: String = json_get(dn0, "created_at")
|
|
|
|
|
let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
|
|
|
|
|
let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
|
|
|
|
|
ts0 > cutoff
|
|
|
|
|
} else { false }
|
|
|
|
|
}
|
|
|
|
|
if found_recent {
|
|
|
|
|
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
|
|
|
|
} else { "" }
|
|
|
|
@@ -429,15 +451,36 @@ fn handle_chat(body: String) -> String {
|
|
|
|
|
let profile_ok: Bool = !str_eq(profile_nodes, "") && !str_eq(profile_nodes, "[]")
|
|
|
|
|
let work_ok: Bool = !str_eq(work_nodes, "") && !str_eq(work_nodes, "[]")
|
|
|
|
|
|
|
|
|
|
// Load the previous session summary. Primary: label-based fetch (stable, written
|
|
|
|
|
// by session_summary_write). Fallback: vector search for SessionSummary nodes.
|
|
|
|
|
// Load the previous session summary. Search by label text + type, then filter by
|
|
|
|
|
// exact label match. Fallback: broader vector search for SessionSummary nodes.
|
|
|
|
|
// Fixes issue #2: prev session summary was never loaded at startup.
|
|
|
|
|
let prev_sum_node: String = engram_get_node_by_label("session:summary")
|
|
|
|
|
let prev_sum_label_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null")
|
|
|
|
|
let prev_summary_raw: String = if prev_sum_label_ok {
|
|
|
|
|
json_get(prev_sum_node, "content")
|
|
|
|
|
// Fixes issue #2b (phantom engram_get_node_by_label replaced with engram_search_json).
|
|
|
|
|
let sum_search_nodes: String = engram_search_json("session:summary SessionSummary", 5)
|
|
|
|
|
let sum_search_ok: Bool = !str_eq(sum_search_nodes, "") && !str_eq(sum_search_nodes, "[]")
|
|
|
|
|
let prev_sum_node_content: String = if sum_search_ok {
|
|
|
|
|
let ss_total: Int = json_array_len(sum_search_nodes)
|
|
|
|
|
let ssi: Int = 0
|
|
|
|
|
let found_content: String = ""
|
|
|
|
|
while ssi < ss_total {
|
|
|
|
|
let ss_node: String = json_array_get(sum_search_nodes, ssi)
|
|
|
|
|
let ss_label: String = json_get(ss_node, "label")
|
|
|
|
|
let ss_type: String = json_get(ss_node, "node_type")
|
|
|
|
|
let ss_content: String = json_get(ss_node, "content")
|
|
|
|
|
let found_content = if str_eq(ss_label, "session:summary") && str_eq(ss_type, "SessionSummary") && !str_eq(ss_content, "") {
|
|
|
|
|
if str_eq(found_content, "") { ss_content } else { found_content }
|
|
|
|
|
} else { found_content }
|
|
|
|
|
let ssi = ssi + 1
|
|
|
|
|
}
|
|
|
|
|
found_content
|
|
|
|
|
} else { "" }
|
|
|
|
|
// Check state first: soul.el pre-loads this at boot (soul_prev_session_summary) — fixes issue #5.
|
|
|
|
|
let soul_cached_sum: String = state_get("soul_prev_session_summary")
|
|
|
|
|
let prev_summary_raw: String = if !str_eq(soul_cached_sum, "") {
|
|
|
|
|
soul_cached_sum
|
|
|
|
|
} else if !str_eq(prev_sum_node_content, "") {
|
|
|
|
|
prev_sum_node_content
|
|
|
|
|
} else {
|
|
|
|
|
let sum_nodes: String = engram_search_json("SessionSummary session:summary previous-session", 3)
|
|
|
|
|
let sum_nodes: String = engram_search_json("SessionSummary previous-session", 3)
|
|
|
|
|
let sum_ok: Bool = !str_eq(sum_nodes, "") && !str_eq(sum_nodes, "[]")
|
|
|
|
|
if sum_ok {
|
|
|
|
|
let sn0: String = json_array_get(sum_nodes, 0)
|
|
|
|
@@ -500,13 +543,13 @@ fn handle_chat(body: String) -> String {
|
|
|
|
|
let has_work: Bool = !str_eq(work_bullets, "")
|
|
|
|
|
let preload: String = if has_profile || has_work || has_prev_summary {
|
|
|
|
|
let summary_section: String = if has_prev_summary {
|
|
|
|
|
"[PREVIOUS SESSION â what we discussed last time]\n" + prev_summary_snip
|
|
|
|
|
"[PREVIOUS SESSION - what we discussed last time]\n" + prev_summary_snip
|
|
|
|
|
} else { "" }
|
|
|
|
|
let profile_section: String = if has_profile {
|
|
|
|
|
"[USER CONTEXT â from memory]\n" + profile_bullets
|
|
|
|
|
"[USER CONTEXT - from memory]\n" + profile_bullets
|
|
|
|
|
} else { "" }
|
|
|
|
|
let work_section: String = if has_work {
|
|
|
|
|
"[ACTIVE WORK â from memory]\n" + work_bullets
|
|
|
|
|
"[ACTIVE WORK - from memory]\n" + work_bullets
|
|
|
|
|
} else { "" }
|
|
|
|
|
let sep_sp: String = if has_prev_summary && (has_profile || has_work) { "\n\n" } else { "" }
|
|
|
|
|
let sep_pw: String = if has_profile && has_work { "\n\n" } else { "" }
|
|
|
|
@@ -1010,7 +1053,7 @@ fn handle_chat_agentic(body: String) -> String {
|
|
|
|
|
if str_eq(screen_action, "hard_bell") {
|
|
|
|
|
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
|
|
|
|
|
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let req_model: String = json_get(body, "model")
|
|
|
|
|
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
|
|
|
|
@@ -1034,7 +1077,14 @@ fn handle_chat_agentic(body: String) -> String {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
|
|
|
|
let agentic_hist: String = state_get(hist_key)
|
|
|
|
|
// Fall back to engram (via session_hist_load) when state is cold — fixes issue #4:
|
|
|
|
|
// named-session history written under session:messages:SESSION_ID was never read back.
|
|
|
|
|
let agentic_hist_state: String = state_get(hist_key)
|
|
|
|
|
let agentic_hist: String = if str_eq(agentic_hist_state, "") && !str_eq(req_session, "") {
|
|
|
|
|
let loaded: String = session_hist_load(req_session)
|
|
|
|
|
if !str_eq(loaded, "") { state_set(hist_key, loaded) }
|
|
|
|
|
if str_eq(loaded, "") { conv_history_load() } else { loaded }
|
|
|
|
|
} else { agentic_hist_state }
|
|
|
|
|
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
|
|
|
|
let ag_is_cont: Bool = str_len(message) < 50 && agentic_hist_len > 0
|
|
|
|
|
let ag_last_entry: String = if ag_is_cont { json_array_get(agentic_hist, agentic_hist_len - 1) } else { "" }
|
|
|
|
@@ -1078,23 +1128,22 @@ fn handle_chat_agentic(body: String) -> String {
|
|
|
|
|
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
|
|
|
|
|
state_set(hist_key, trimmed)
|
|
|
|
|
// Persist to engram for cross-restart continuity.
|
|
|
|
|
// Named sessions get session-scoped labels, fixing ephemeral-only limitation (issue #4).
|
|
|
|
|
// Named sessions use session_hist_save (session:messages:SESSION_ID label) so that
|
|
|
|
|
// session_hist_load can recover them on the next restart — fixes issue #4.
|
|
|
|
|
// The old conv:history:SESSION_ID label was a dead write (never read back).
|
|
|
|
|
if str_eq(hist_key, "conv_history") {
|
|
|
|
|
conv_history_persist(trimmed)
|
|
|
|
|
} else {
|
|
|
|
|
if !str_eq(trimmed, "") && !str_eq(trimmed, "[]") {
|
|
|
|
|
let sess_hist_label: String = "conv:history:" + req_session
|
|
|
|
|
let sess_hist_tags: String = "[\"session-history\",\"persistent\"]"
|
|
|
|
|
let sess_hist_id: String = engram_node_full(
|
|
|
|
|
trimmed, "Conversation", sess_hist_label,
|
|
|
|
|
el_from_float(0.6), el_from_float(0.7), el_from_float(0.8),
|
|
|
|
|
"Episodic", sess_hist_tags
|
|
|
|
|
)
|
|
|
|
|
if str_eq(sess_hist_id, "") {
|
|
|
|
|
println("[chat] agentic: named session history persist failed for session=" + req_session)
|
|
|
|
|
}
|
|
|
|
|
session_hist_save(req_session, trimmed)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Write automatic session summary so cross-session continuity is maintained
|
|
|
|
|
// on the agentic path too — fixes issue #7.
|
|
|
|
|
let ag_auto_sum: String = session_summary_autogenerate(trimmed)
|
|
|
|
|
if !str_eq(ag_auto_sum, "") {
|
|
|
|
|
let discard_ag_sum: String = session_summary_write(ag_auto_sum)
|
|
|
|
|
}
|
|
|
|
|
true
|
|
|
|
|
} else { false }
|
|
|
|
|
|
|
|
|
@@ -1559,12 +1608,20 @@ fn session_summary_write(summary_text: String) -> String {
|
|
|
|
|
let ts_str: String = int_to_str(ts)
|
|
|
|
|
let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str
|
|
|
|
|
// Delete old node before writing so duplicate label nodes don't accumulate.
|
|
|
|
|
let old_node: String = engram_get_node_by_label("session:summary")
|
|
|
|
|
let old_ok: Bool = !str_eq(old_node, "") && !str_eq(old_node, "null")
|
|
|
|
|
if old_ok {
|
|
|
|
|
let old_id: String = json_get(old_node, "id")
|
|
|
|
|
if !str_eq(old_id, "") {
|
|
|
|
|
engram_forget(old_id)
|
|
|
|
|
// engram_get_node_by_label doesn't exist — search by label text and filter by exact match.
|
|
|
|
|
let old_search: String = engram_search_json("session:summary SessionSummary", 5)
|
|
|
|
|
let old_search_ok: Bool = !str_eq(old_search, "") && !str_eq(old_search, "[]")
|
|
|
|
|
if old_search_ok {
|
|
|
|
|
let os_total: Int = json_array_len(old_search)
|
|
|
|
|
let osi: Int = 0
|
|
|
|
|
while osi < os_total {
|
|
|
|
|
let os_node: String = json_array_get(old_search, osi)
|
|
|
|
|
let os_label: String = json_get(os_node, "label")
|
|
|
|
|
let os_id: String = json_get(os_node, "id")
|
|
|
|
|
if str_eq(os_label, "session:summary") && !str_eq(os_id, "") {
|
|
|
|
|
engram_forget(os_id)
|
|
|
|
|
}
|
|
|
|
|
let osi = osi + 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
|
|
|
|
@@ -1595,12 +1652,13 @@ fn session_summary_autogenerate(hist: String) -> String {
|
|
|
|
|
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 msg: String = json_get(entry, "content")
|
|
|
|
|
let snip: String = if str_len(msg) > 80 { str_slice(msg, 0, 80) } else { msg }
|
|
|
|
|
// Mutations at top level of while body via if-expressions — inner if blocks don't escape scope.
|
|
|
|
|
let snippets = if str_eq(role, "user") && !str_eq(snip, "") {
|
|
|
|
|
if str_eq(snippets, "") { snip } else { snippets + "; " + snip }
|
|
|
|
|
} else { snippets }
|
|
|
|
|
let count = if str_eq(role, "user") && !str_eq(snip, "") { count + 1 } else { count }
|
|
|
|
|
let i = i + 1
|
|
|
|
|
}
|
|
|
|
|
if str_eq(snippets, "") { return "" }
|
|
|
|
|