diff --git a/chat.el b/chat.el index e80dc0a..b6e6ea3 100644 --- a/chat.el +++ b/chat.el @@ -105,6 +105,54 @@ fn engram_render_nodes(nodes_json: String) -> String { return result } +// engram_render_ctx — render the mixed ctx string returned by engram_compile. +// engram_compile may return: a JSON array, a single JSON object, two parts joined by \n, +// or a plain string fallback. This function dispatches to the right renderer for each +// shape so build_system_prompt always passes human-readable bullets to the LLM rather +// than raw JSON. +fn engram_render_ctx(ctx: String) -> String { + if str_eq(ctx, "") { return "" } + if str_starts_with(ctx, "[") { + let nl: Int = str_index_of(ctx, "\n") + if nl < 0 { + let r: String = engram_render_nodes(ctx) + if !str_eq(r, "") { return r } + return "" + } + let part1: String = str_slice(ctx, 0, nl) + let part2: String = str_slice(ctx, nl + 1, str_len(ctx)) + let r1: String = engram_render_nodes(part1) + let r2: String = if str_starts_with(part2, "[") { + engram_render_nodes(part2) + } else { + if str_starts_with(part2, "{") { engram_render_node(part2) } else { "" } + } + if str_eq(r1, "") { return r2 } + if str_eq(r2, "") { return r1 } + return r1 + "\n" + r2 + } + if str_starts_with(ctx, "{") { + let nl: Int = str_index_of(ctx, "\n") + if nl < 0 { + let r: String = engram_render_node(ctx) + if !str_eq(r, "") { return r } + return "" + } + let part1: String = str_slice(ctx, 0, nl) + let part2: String = str_slice(ctx, nl + 1, str_len(ctx)) + let r1: String = engram_render_node(part1) + let r2: String = if str_starts_with(part2, "[") { + engram_render_nodes(part2) + } else { + if str_starts_with(part2, "{") { engram_render_node(part2) } else { "" } + } + if str_eq(r1, "") { return r2 } + if str_eq(r2, "") { return r1 } + return r1 + "\n" + r2 + } + return ctx +} + // engram_dedup_nodes — deduplicate a merged JSON node array by id / content fingerprint. // Fixes Issue #2: prevents same node appearing from both activation and search passes. fn engram_dedup_nodes(nodes_json: String) -> String { @@ -173,122 +221,6 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String { } if str_eq(selected_nodes, "") { return "" } return "[" + selected_nodes + "]" -}ory.el" - -fn chat_default_model() -> String { - let m: String = state_get("soul_model") - if !str_eq(m, "") { - return m - } - let e: String = env("SOUL_LLM_MODEL") - if !str_eq(e, "") { - return e - } - return "claude-sonnet-4-5" -} - -// 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. -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") - - // 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) - 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, ".", "")) - 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 created_ts: Int = str_to_int(created_str) - let age_secs: Int = now_ts - created_ts - let age_days: Int = 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). -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 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) - // Only include reasonably relevant nodes (threshold=25) - let above_thresh: Bool = score >= 25 - // 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 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 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,", "") - return c9 } fn engram_compile(intent: String) -> String { @@ -412,8 +344,10 @@ fn build_system_prompt(ctx: String, chat_mode: Bool) -> String { } // Issue #8 fix: engram_block at END for strongest attention. Issue #10: clear label. - let engram_block: String = if str_eq(ctx, "") { "" } else { - "\n\n[RETRIEVED MEMORY — compiled from your graph for this turn]\n" + ctx + // Issue #3 fix: render raw JSON nodes to human-readable bullets before sending to LLM. + let rendered_ctx: String = engram_render_ctx(ctx) + let engram_block: String = if str_eq(rendered_ctx, "") { "" } else { + "\n\n[RETRIEVED MEMORY — compiled from your graph for this turn]\n" + rendered_ctx } return identity + date_line + voice_rules + security_rules + capability_rules + no_tools_rule + identity_block + safety_block + engram_block @@ -1172,7 +1106,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 }