diff --git a/chat.el b/chat.el index 826242c..7ababc8 100644 --- a/chat.el +++ b/chat.el @@ -119,7 +119,32 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String { 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 + let c15: String = str_replace(c14, "\"_sel_15\":1,", "") + let c16: String = str_replace(c15, "\"_sel_16\":1,", "") + let c17: String = str_replace(c16, "\"_sel_17\":1,", "") + let c18: String = str_replace(c17, "\"_sel_18\":1,", "") + let c19: String = str_replace(c18, "\"_sel_19\":1,", "") + let c20: String = str_replace(c19, "\"_sel_20\":1,", "") + let c21: String = str_replace(c20, "\"_sel_21\":1,", "") + let c22: String = str_replace(c21, "\"_sel_22\":1,", "") + let c23: String = str_replace(c22, "\"_sel_23\":1,", "") + let c24: String = str_replace(c23, "\"_sel_24\":1,", "") + let c25: String = str_replace(c24, "\"_sel_25\":1,", "") + let c26: String = str_replace(c25, "\"_sel_26\":1,", "") + let c27: String = str_replace(c26, "\"_sel_27\":1,", "") + let c28: String = str_replace(c27, "\"_sel_28\":1,", "") + let c29: String = str_replace(c28, "\"_sel_29\":1,", "") + let c30: String = str_replace(c29, "\"_sel_30\":1,", "") + let c31: String = str_replace(c30, "\"_sel_31\":1,", "") + let c32: String = str_replace(c31, "\"_sel_32\":1,", "") + let c33: String = str_replace(c32, "\"_sel_33\":1,", "") + let c34: String = str_replace(c33, "\"_sel_34\":1,", "") + let c35: String = str_replace(c34, "\"_sel_35\":1,", "") + let c36: String = str_replace(c35, "\"_sel_36\":1,", "") + let c37: String = str_replace(c36, "\"_sel_37\":1,", "") + let c38: String = str_replace(c37, "\"_sel_38\":1,", "") + let c39: String = str_replace(c38, "\"_sel_39\":1,", "") + return c39 } // engram_split_topics — split message into sub-queries on explicit conjunctions. @@ -170,9 +195,11 @@ fn engram_extract_entities(message: String) -> String { let already_have: Bool = str_contains(entities, word) let should_add: Bool = is_capital && !is_stop && !already_have && word_len >= 3 let entities = if should_add { - let entity_count = entity_count + 1 if str_eq(entities, "") { word } else { entities + "\n" + word } } else { entities } + // Increment entity_count at the while-body level so the binding escapes the + // if-expression scope and the entity_count < 10 guard actually terminates early. + let entity_count = if should_add { entity_count + 1 } else { entity_count } let pos = if wend > pos { wend + 1 } else { pos + 1 } } return entities @@ -201,10 +228,12 @@ fn engram_detect_recall_intent(message: String) -> Bool { || str_contains(message, "what happened with") } -// engram_is_continuation — semantic continuation detection replacing the brittle 50-char -// threshold. Returns true when message starts with a pronoun, continuation opener, or is -// < 80 chars (raised from 50 to catch "Can you remind me what Prism's architecture -// looks like?" at 57 chars which is clearly a continuation in an active thread). +// engram_is_continuation — detect whether a message continues the active thread. +// Returns true only when the message starts with a pronoun or an unambiguous +// discourse continuation marker. Does NOT classify by message length: short messages +// that introduce a new topic (e.g. "What is quantum computing?") are not continuations. +// Does NOT classify interrogative starters (How, Why, When, Where, What about) as +// continuations — these commonly open new topics and the false-positive cost is too high. fn engram_is_continuation(message: String, hist_len: Int) -> Bool { if hist_len <= 0 { return false } let has_pronoun: Bool = str_starts_with(message, "It ") @@ -216,6 +245,7 @@ fn engram_is_continuation(message: String, hist_len: Int) -> Bool { || str_starts_with(message, "She ") || str_starts_with(message, "she ") || str_starts_with(message, "We ") || str_starts_with(message, "we ") if has_pronoun { return true } + // Only unambiguous discourse connectors that cannot open a new topic on their own. let is_cont_opener: Bool = str_starts_with(message, "Go on") || str_starts_with(message, "go on") || str_starts_with(message, "Continue") || str_starts_with(message, "continue") @@ -224,12 +254,7 @@ fn engram_is_continuation(message: String, hist_len: Int) -> Bool { || str_starts_with(message, "Ok") || str_starts_with(message, "ok") || str_starts_with(message, "And ") || str_starts_with(message, "and ") || str_starts_with(message, "But ") || str_starts_with(message, "but ") - || str_starts_with(message, "What about") || str_starts_with(message, "what about") - || str_starts_with(message, "Why ") || str_starts_with(message, "why ") - || str_starts_with(message, "How ") || str_starts_with(message, "how ") - || str_starts_with(message, "When ") || str_starts_with(message, "when ") if is_cont_opener { return true } - if str_len(message) < 80 { return true } return false } @@ -306,6 +331,26 @@ fn engram_compile(intent: String) -> String { } } else { "" } + // Fourth+ topic segments: engram_split_topics is recursive and can produce 4+ lines. + // Rather than hardcoding each topic index, collect everything after the third topic + // as a single combined search query so no segments are silently dropped. + // This handles inputs like "a and b and c and d" (4 topics). + let nodes3: String = if has_multi_topic { + let nl0: Int = str_index_of(topics, "\n") + let rest1: String = str_slice(topics, nl0 + 1, str_len(topics)) + let nl1: Int = str_index_of(rest1, "\n") + if nl1 < 0 { "" } else { + let rest2: String = str_slice(rest1, nl1 + 1, str_len(rest1)) + let nl2: Int = str_index_of(rest2, "\n") + if nl2 < 0 { "" } else { + // Remainder after the third segment — may span one or more topics. + // Search with the remaining text as-is; engram_compile_multi handles it. + let rest3: String = str_slice(rest2, nl2 + 1, str_len(rest2)) + if str_eq(rest3, "") { "" } else { engram_compile_multi(rest3) } + } + } + } else { "" } + // Issue 2 cont.: entity 0 dedicated search (15 candidates, ranked 6). let entity_nodes0: String = if has_entities { let nl_e0: Int = str_index_of(entity_list, "\n") @@ -342,6 +387,7 @@ fn engram_compile(intent: String) -> String { // Merge all pools, deduplicating at each step. let merged: String = engram_nodes_merge(nodes0, nodes1) let merged: String = engram_nodes_merge(merged, nodes2) + let merged: String = engram_nodes_merge(merged, nodes3) let merged: String = engram_nodes_merge(merged, entity_nodes0) let merged: String = engram_nodes_merge(merged, entity_nodes1) let merged: String = engram_nodes_merge(merged, recall_boost) @@ -388,8 +434,16 @@ fn engram_compile(intent: String) -> String { if str_eq(ctx, "") { return "" } - // Issue 7 fix: safe JSON truncation — find last closing brace before budget cap. + // Safe JSON truncation — find last closing brace before budget cap. // Budget raised from 6000 to 8000 for the larger multi-topic pool. + // + // Issue 8 fix: ctx may be main_part (JSON array) + "\n" + affective_part (single JSON + // object). When truncation cuts into the affective_part, appending "]" would produce + // "[array_content]{partial_bell_node}]" — not valid JSON. Guard: only append "]" when + // the truncation point falls strictly within the main array portion (before the "\n" + // separator). If the cut falls in the affective part, drop the partial object entirely + // and return only the complete main array. If there is no separator (ctx is a plain + // array with no affective part), the original append-"]" behaviour applies. let budget: Int = 8000 if str_len(ctx) <= budget { return ctx } let search_end: Int = budget - 1 @@ -403,7 +457,18 @@ fn engram_compile(intent: String) -> String { } if found_pos < 0 { return str_slice(ctx, 0, budget) } let truncated: String = str_slice(ctx, 0, found_pos + 1) - if str_starts_with(ctx, "[") { return truncated + "]" } + if str_starts_with(ctx, "[") { + // Determine whether this ctx has a separate affective object appended after the array. + // The format is: main_array + "\n" + bell_object. Find the array boundary. + let nl_pos: Int = str_index_of(ctx, "\n") + let has_affective_sep: Bool = nl_pos > 0 && nl_pos < str_len(ctx) - 1 + if has_affective_sep && found_pos > nl_pos { + // Truncation fell inside the affective_part — drop it and return just the main array. + return str_slice(ctx, 0, nl_pos) + } + // Truncation is within the main array — close it properly. + return truncated + "]" + } return truncated } fn json_safe(s: String) -> String { @@ -505,23 +570,19 @@ fn hist_trim(hist: String) -> String { // a bell event. If it did, write a preservation node to engram so the distress exchange // survives the 20-turn window. The LLM window drops it; engram retains it permanently // and engram_compile will surface it again via the affective context path. +// +// Fix: use json_array_get for structural parsing (immune to {"role": appearing in +// message content) — same fix applied to hist_trim. The old str_index_of("{\"role\":") +// pattern could corrupt history when any message contained that literal string. fn hist_trim_with_bell_guard(hist: String) -> String { - // Extract the first turn (should be a user message) to inspect it. - let inner: String = str_slice(hist, 1, str_len(hist) - 1) - let marker: String = "{\"role\":" - let i1: Int = str_index_of(inner, marker) - // i1 is the start of the first entry within inner. - // Find where the second entry begins to delimit the first entry's JSON. - let tail1: String = str_slice(inner, i1 + 1, str_len(inner)) - let i2: Int = str_index_of(tail1, marker) - // The first entry spans from i1 to (i1 + 1 + i2 - 1) within inner. - let first_entry_raw: String = if i2 > 0 { - str_slice(inner, i1, i1 + 1 + i2 - 1) - } else { - str_slice(inner, i1, str_len(inner)) - } - let first_role: String = json_get(first_entry_raw, "role") - let first_content: String = json_get(first_entry_raw, "content") + let total: Int = json_array_len(hist) + // Safety: never trim below 2 entries. + if total <= 2 { return hist } + + // Extract the first entry structurally — immune to content containing {"role": + let first_entry: String = json_array_get(hist, 0) + let first_role: String = json_get(first_entry, "role") + let first_content: String = json_get(first_entry, "content") // Only inspect user turns — assistant content doesn't carry bell signals. let bell_level: String = if str_eq(first_role, "user") { @@ -554,13 +615,9 @@ fn hist_trim_with_bell_guard(hist: String) -> String { ) } - // Now perform the standard trim (drop oldest 2 entries = 1 user + 1 assistant pair). - let tail2: String = str_slice(tail1, i2 + 1, str_len(tail1)) - let i3: Int = str_index_of(tail2, marker) - if i3 >= 0 { - return "[" + str_slice(tail2, i3, str_len(tail2)) + "]" - } - return hist + // Now perform the standard trim: drop entries 0 and 1 (oldest user+assistant pair). + // Reuse hist_trim's structural approach — rebuild from entry 2 onward. + return hist_trim(hist) } // clean_llm_response — strips GPT-2 BPE byte-to-unicode artifacts that vLLM @@ -1022,15 +1079,11 @@ fn agentic_tools_all() -> String { fn call_mcp_bridge(tool_name: String, tool_input: String) -> String { let eff_input: String = if str_eq(tool_input, "") { "{}" } else { tool_input } let body: String = "{\"name\":\"" + tool_name + "\",\"input\":" + eff_input + "}" - // Issue #12: previously used a fixed path /tmp/neuron-mcp-call.json. - // Under concurrent load (64 worker threads), two simultaneous MCP tool calls - // race on this file — one call sends the other's input to the bridge. - // Fix: monotonic sequence counter makes the path unique per call. - let mcp_seq_s: String = state_get("mcp_call_seq") - let mcp_seq_n: Int = if str_eq(mcp_seq_s, "") { 0 } else { str_to_int(mcp_seq_s) } - let mcp_seq_next: Int = mcp_seq_n + 1 - state_set("mcp_call_seq", int_to_str(mcp_seq_next)) - let tmp: String = "/tmp/neuron-mcp-call-" + int_to_str(time_now()) + "-" + int_to_str(mcp_seq_next) + ".json" + // Issue #12: previously used a fixed path /tmp/neuron-mcp-call.json, then a + // time+seq path that still raced (time_now() is 1s granularity; non-atomic seq RMW). + // Fix: uuid_v4() provides collision-free uniqueness regardless of concurrency — + // same approach used by next_bridge_id(). No state read/write needed. + let tmp: String = "/tmp/neuron-mcp-call-" + uuid_v4() + ".json" fs_write(tmp, body) return exec_capture("curl -s --max-time 30 -X POST http://127.0.0.1:7771/mcp/call -H 'Content-Type: application/json' -d @" + tmp) } @@ -1374,7 +1427,7 @@ fn handle_chat_agentic(body: String) -> String { let session_valid: Bool = if str_eq(req_session, "") { true } else { - !str_contains(session_get(req_session), "\"error\"") + session_exists(req_session) } if !session_valid { return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}" @@ -2014,6 +2067,7 @@ fn auto_persist(req: String, resp: String) -> Void { "session_bell_signal:" + sess_id } state_set(signal_key, safe_summary) + } if str_eq(conv_node_id, "") { println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")") }