Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f73c913498 | |||
| aaada3770a | |||
| a0299c0a89 |
@@ -12,114 +12,129 @@ fn chat_default_model() -> String {
|
||||
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
|
||||
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
|
||||
// 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.
|
||||
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
|
||||
// nodes to the bottom so they get trimmed when we cap context size.
|
||||
// Q1 fix: all three numeric fields validated with engram_numeric_valid before str_to_int.
|
||||
fn engram_score_node(node_json: String) -> Int {
|
||||
let salience_str: String = json_get(node_json, "salience")
|
||||
let importance_str: String = json_get(node_json, "importance")
|
||||
let created_str: String = json_get(node_json, "created_at")
|
||||
let updated_str: String = json_get(node_json, "updated_at")
|
||||
let tier_str: String = json_get(node_json, "tier")
|
||||
|
||||
// Parse as floats via * 100 integer arithmetic (el has no float math)
|
||||
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
|
||||
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
|
||||
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
|
||||
// Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults.
|
||||
// parse_float_x100 correctly handles single-decimal floats like "0.9" -> 90.
|
||||
let salience_100: Int = if !engram_numeric_valid(salience_str) { 70 } else {
|
||||
let s: Int = parse_float_x100(salience_str)
|
||||
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
||||
}
|
||||
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
|
||||
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
||||
let importance_100: Int = if !engram_numeric_valid(importance_str) { 70 } else {
|
||||
let v: Int = parse_float_x100(importance_str)
|
||||
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
||||
}
|
||||
|
||||
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
||||
let now_ts: Int = time_now()
|
||||
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
|
||||
let recency_100: Int = if !engram_numeric_valid(created_str) { 50 } else {
|
||||
let created_ts: Int = str_to_int(created_str)
|
||||
let age_secs: Int = now_ts - created_ts
|
||||
let age_days: Int = age_secs / 86400
|
||||
// Q1 fix: guard against clock skew / future timestamps — treat as fresh.
|
||||
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
|
||||
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||
if decay < 10 { 10 } else { decay }
|
||||
}
|
||||
|
||||
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
|
||||
return salience_100 * importance_100 * recency_100 / 10000
|
||||
}
|
||||
|
||||
// engram_compile_ranked — build a context string from a JSON array of node objects,
|
||||
// ordered best-first by score. Only nodes above a minimum score (25 = salience 0.5 *
|
||||
// importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most
|
||||
// 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).
|
||||
// engram_compile_ranked — build a ranked list of nodes, best-first by score.
|
||||
// Fix (Issue #11): uses "|N|" index tracking instead of _sel_N JSON mutation,
|
||||
// which leaked sentinel fields into the node objects passed to the LLM.
|
||||
// Works correctly for any input array size — no sentinel cleanup needed.
|
||||
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||
if str_eq(nodes_json, "") { return "" }
|
||||
if str_eq(nodes_json, "[]") { return "" }
|
||||
let total: Int = json_array_len(nodes_json)
|
||||
if total == 0 { return "" }
|
||||
|
||||
// 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 selected_indices: String = ""
|
||||
let selected_nodes: String = ""
|
||||
let pass: Int = 0
|
||||
|
||||
while pass < max_nodes && pass < total {
|
||||
// Find the unselected node with the highest score
|
||||
let best_idx: Int = -1
|
||||
let best_score: Int = -1
|
||||
let ci: Int = 0
|
||||
while ci < total {
|
||||
let node: String = json_array_get(nodes_json, ci)
|
||||
let score: Int = engram_score_node(node)
|
||||
// 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.
|
||||
// Threshold: includes moderately-relevant older nodes (score >= 15).
|
||||
let above_thresh: Bool = score >= 15
|
||||
// Check this index wasn't already selected (sentinel: look for idx marker)
|
||||
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
|
||||
let already_picked: Bool = str_contains(selected, idx_marker)
|
||||
let idx_marker: String = "|" + int_to_str(ci) + "|"
|
||||
let already_picked: Bool = str_contains(selected_indices, idx_marker)
|
||||
let is_better: Bool = score > best_score && above_thresh && !already_picked
|
||||
let best_score = if is_better { score } else { best_score }
|
||||
let best_idx = if is_better { ci } else { best_idx }
|
||||
let ci = ci + 1
|
||||
}
|
||||
|
||||
// No more qualifying nodes
|
||||
if best_idx < 0 {
|
||||
let pass = total // break
|
||||
} else {
|
||||
let chosen: String = json_array_get(nodes_json, best_idx)
|
||||
let sep: String = if str_eq(selected, "") { "" } else { "," }
|
||||
// Append the index sentinel inline so already_picked checks work
|
||||
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 sep: String = if str_eq(selected_nodes, "") { "" } else { "," }
|
||||
let selected_nodes = selected_nodes + sep + chosen
|
||||
let selected_indices = selected_indices + "|" + int_to_str(best_idx) + "|"
|
||||
}
|
||||
let pass = pass + 1
|
||||
}
|
||||
|
||||
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
|
||||
if str_eq(selected_nodes, "") { return "" }
|
||||
return "[" + selected_nodes + "]"
|
||||
}
|
||||
|
||||
// engram_split_topics — split message into sub-queries on explicit conjunctions.
|
||||
@@ -265,6 +280,39 @@ fn engram_nodes_merge(a: String, b: String) -> String {
|
||||
return engram_dedup_nodes("[" + ai + "," + bi + "]")
|
||||
}
|
||||
|
||||
// id_in_seen — check if node_id appears in the comma-delimited seen accumulator.
|
||||
// Pads both sides with commas to avoid false substring matches.
|
||||
fn id_in_seen(node_id: String, seen: String) -> Bool {
|
||||
if str_eq(node_id, "") { return false }
|
||||
if str_eq(seen, "") { return false }
|
||||
return str_contains("," + seen + ",", "," + node_id + ",")
|
||||
}
|
||||
|
||||
// add_to_seen — append node_id to the comma-delimited seen accumulator.
|
||||
fn add_to_seen(seen: String, node_id: String) -> String {
|
||||
if str_eq(node_id, "") { return seen }
|
||||
if str_eq(seen, "") { return node_id }
|
||||
return seen + "," + node_id
|
||||
}
|
||||
|
||||
// engram_extract_ids — extract all non-empty "id" fields from a JSON node array
|
||||
// into a comma-delimited string for use with id_in_seen / add_to_seen.
|
||||
fn engram_extract_ids(nodes_json: String) -> String {
|
||||
if str_eq(nodes_json, "") { return "" }
|
||||
if str_eq(nodes_json, "[]") { return "" }
|
||||
let total: Int = json_array_len(nodes_json)
|
||||
if total == 0 { return "" }
|
||||
let ids: String = ""
|
||||
let i: Int = 0
|
||||
while i < total {
|
||||
let node: String = json_array_get(nodes_json, i)
|
||||
let nid: String = json_get(node, "id")
|
||||
let ids = if str_eq(nid, "") { ids } else { add_to_seen(ids, nid) }
|
||||
let i = i + 1
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
fn engram_compile(intent: String) -> String {
|
||||
// Issue 1: decompose multi-topic messages into sub-queries.
|
||||
let topics: String = engram_split_topics(intent)
|
||||
@@ -357,11 +405,11 @@ fn engram_compile(intent: String) -> String {
|
||||
} else { "" }
|
||||
} else { "" }
|
||||
|
||||
// Affective context: always include the most recent high-emotion memory within 72h.
|
||||
// Affective context: always include the most recent high-emotion memory within 14 days.
|
||||
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
|
||||
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
|
||||
let now_ts: Int = time_now()
|
||||
let cutoff_ts: Int = now_ts - 259200
|
||||
let cutoff_ts: Int = now_ts - 1209600
|
||||
let recent_bell: String = if bell_ok {
|
||||
let bn0: String = json_array_get(bell_nodes, 0)
|
||||
let bn_content: String = json_get(bn0, "content")
|
||||
@@ -376,16 +424,53 @@ fn engram_compile(intent: String) -> String {
|
||||
let ca: String = json_get(bn0, "created_at")
|
||||
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
|
||||
}
|
||||
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
|
||||
// Q1 fix: validate bell timestamp before str_to_int.
|
||||
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 { "" }
|
||||
} else { "" }
|
||||
let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" }
|
||||
// Positive emotion context: check for recent joy/success moments within 14 days.
|
||||
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 main_part: String = if has_main { merged_nodes } else { scan_part }
|
||||
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.
|
||||
@@ -556,29 +641,58 @@ 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".
|
||||
// Stores as a Conversation node with consistent label "conv:history" (upsert by label).
|
||||
// Q3/Q6 fix: added partial-write guard and failure logging.
|
||||
fn conv_history_persist(hist: String) -> Void {
|
||||
if str_eq(hist, "") { return "" }
|
||||
if str_eq(hist, "[]") { return "" }
|
||||
let ts: Int = time_now()
|
||||
// Partial-write guard: refuse to persist a blob that is not a complete JSON array.
|
||||
// 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 discard: String = engram_node_full(
|
||||
let node_id: String = engram_node_full(
|
||||
hist, "Conversation", "conv:history",
|
||||
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
|
||||
"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.
|
||||
// Returns the most recent "conv:history" node content, or "" if none found.
|
||||
// Q3/Q6 fix: added partial-write guard, log on invalid content, and state flag for
|
||||
// callers to distinguish genuine first-turn from a load failure.
|
||||
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)
|
||||
if str_eq(results, "") { return "" }
|
||||
if str_eq(results, "") {
|
||||
// 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 "" }
|
||||
let node: String = json_array_get(results, 0)
|
||||
let content: String = json_get(node, "content")
|
||||
// Validate it looks like a JSON array
|
||||
if !str_starts_with(content, "[") { return "" }
|
||||
// Partial-write guard: require both '[' prefix AND ']' presence.
|
||||
if !str_starts_with(content, "[") || !str_contains(content, "]") {
|
||||
println("[chat] conv_history_load: vector search result content invalid — treating as first turn")
|
||||
state_set("conv_history_load_failed", "1")
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -593,6 +707,7 @@ fn handle_chat(body: String) -> String {
|
||||
// /api/chat requests without session_id race on this read-append-write.
|
||||
let state_hist: String = state_get("conv_history")
|
||||
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) }
|
||||
|
||||
// Issue 8 fix: use semantic continuation detection instead of brittle 50-char threshold.
|
||||
@@ -607,31 +722,76 @@ fn handle_chat(body: String) -> String {
|
||||
message
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
// Cross-session affective context: check engram for recent distress/positive signals
|
||||
// within 72h and prepend a care directive if found. Runs every turn so the directive
|
||||
// is present throughout the session, not just on turn 1.
|
||||
let affective_prefix: String = {
|
||||
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
|
||||
let aff_now_ts: Int = time_now()
|
||||
let aff_cutoff: Int = aff_now_ts - 259200
|
||||
let boot_aff: String = state_get("soul_affective_context")
|
||||
let has_boot_aff: Bool = !str_eq(boot_aff, "")
|
||||
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
|
||||
let found_recent_dist: Bool = if has_boot_aff {
|
||||
true
|
||||
} 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 }
|
||||
if found_recent {
|
||||
if found_recent_dist {
|
||||
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
||||
} else { "" }
|
||||
} else { "" }
|
||||
} else {
|
||||
if found_recent_pos {
|
||||
"[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)
|
||||
// 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)
|
||||
@@ -648,21 +808,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 { "" }
|
||||
@@ -672,15 +835,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 { "" }
|
||||
@@ -690,24 +855,27 @@ 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 { "" }
|
||||
|
||||
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, "") { "" } else { "- " + ss0 }
|
||||
if str_eq(ss0, "") || id_in_seen(sn0_id, seen_ids) { "" } else { "- " + ss0 }
|
||||
} else { "" }
|
||||
|
||||
let hp: Bool = !str_eq(profile_bullets, "")
|
||||
@@ -727,8 +895,28 @@ fn handle_chat(body: String) -> String {
|
||||
preload
|
||||
} 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 {
|
||||
system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
|
||||
system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + rendered_hist
|
||||
} else {
|
||||
system + session_preload
|
||||
}
|
||||
@@ -764,12 +952,43 @@ 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 { "[]" }
|
||||
strengthen_chat_nodes(act_out)
|
||||
|
||||
return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + "}"
|
||||
// Q3 fix: surface history load failure in the response envelope so callers can
|
||||
// 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 {
|
||||
@@ -1209,7 +1428,9 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
|
||||
// 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.
|
||||
let history: String = state_get("conversation_history")
|
||||
// Fix(issue #9): "conversation_history" key was never written; history lives under "conv_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_action: String = json_get(screen_result, "action")
|
||||
if str_eq(screen_action, "hard_bell") {
|
||||
@@ -1863,3 +2084,56 @@ fn strengthen_chat_nodes(activation_nodes: String) -> Void {
|
||||
let i = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// session_summary_autogenerate — build a minimal summary from conversation history without LLM.
|
||||
// Extracts user message snippets (first 80 chars each, up to 5 turns).
|
||||
// Called by the session-end hook when >= 5 complete turns have occurred.
|
||||
fn session_summary_autogenerate(hist: String) -> String {
|
||||
if str_eq(hist, "") { return "" }
|
||||
if str_eq(hist, "[]") { return "" }
|
||||
let total: Int = json_array_len(hist)
|
||||
if total == 0 { return "" }
|
||||
let snippets: String = ""
|
||||
let count: Int = 0
|
||||
let i: Int = 0
|
||||
while i < total && count < 5 {
|
||||
let entry: String = json_array_get(hist, i)
|
||||
let role: String = json_get(entry, "role")
|
||||
if str_eq(role, "user") {
|
||||
let msg: String = json_get(entry, "content")
|
||||
let snip: String = if str_len(msg) > 80 { str_slice(msg, 0, 80) } else { msg }
|
||||
let snippets = if str_eq(snippets, "") { snip } else { snippets + "; " + snip }
|
||||
let count = count + 1
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
if str_eq(snippets, "") { return "" }
|
||||
return "Session covered: " + snippets
|
||||
}
|
||||
|
||||
// session_summary_write_dated — write a SessionSummary node with a caller-supplied dated label.
|
||||
// Unlike a global-label write, this does NOT delete old nodes — each session accumulates its
|
||||
// own node so engram_search_json("session:summary") can return multiple past sessions.
|
||||
// The label must be unique per session (e.g. "session:summary:<boot_ts>").
|
||||
// Uses salience 0.85/importance 0.85 (two-decimal) to avoid the single-decimal parse bug.
|
||||
fn session_summary_write_dated(summary_text: String, label: String) -> String {
|
||||
if str_eq(summary_text, "") { return "" }
|
||||
if str_eq(label, "") { return "" }
|
||||
let safe_text: String = str_replace(summary_text, "\"", "'")
|
||||
let trimmed: String = if str_len(safe_text) > 800 { str_slice(safe_text, 0, 800) } else { safe_text }
|
||||
let ts: Int = time_now()
|
||||
let ts_str: String = int_to_str(ts)
|
||||
let content: String = "[session-summary] " + trimmed + " | ts:" + ts_str
|
||||
let tags: String = "[\"SessionSummary\",\"session-summary\",\"previous-session\",\"consolidate\"]"
|
||||
let node_id: String = engram_node_full(
|
||||
content, "SessionSummary", label,
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(1.0),
|
||||
"Episodic", tags
|
||||
)
|
||||
if str_eq(node_id, "") {
|
||||
println("[chat] session_summary_write_dated: engram write failed — summary node lost (label=" + label + ")")
|
||||
return ""
|
||||
}
|
||||
println("[chat] session_summary_write_dated: wrote SessionSummary (" + int_to_str(str_len(content)) + " chars) label=" + label + " -> " + node_id)
|
||||
return node_id
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user