From 9818b2daad66d7b7893cd1f8103d53f3257f7338 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 15 Jun 2026 12:14:52 -0500 Subject: [PATCH] fix(chat): thread-aware activation for conversation continuity Short/ambiguous messages (< 50 chars) now use the last reply as the engram activation seed instead of the bare message. Prevents strong off-topic memory nodes from hijacking replies when the user is clearly continuing an existing thread. Also gives handle_chat_agentic session continuity: reads/writes history keyed by session_id (falling back to global conv_history), seeds the LLM messages array with prior turns, and saves replies back so the next turn has context. --- chat.el | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 190 insertions(+), 9 deletions(-) diff --git a/chat.el b/chat.el index fc5829b..3e7f903 100644 --- a/chat.el +++ b/chat.el @@ -156,13 +156,27 @@ fn handle_chat(body: String) -> String { return "{\"error\":\"message is required\",\"response\":\"\"}" } - let ctx: String = engram_compile(message) - let system: String = build_system_prompt(ctx) - - // Load from state; if empty, try to recover from engram (cross-restart continuity) + // Load history BEFORE compiling context so we can anchor activation to the thread. 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_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) } + + // Thread-aware activation: short/ambiguous messages (continuations like "go on", + // "what else?", "yes") activate on the last reply instead of the bare message. + // This prevents a strong off-topic memory node from hijacking the reply when the + // user is clearly continuing an existing thread. + let is_continuation: Bool = str_len(message) < 50 && hist_len > 0 + let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" } + let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" } + let thread_snip: String = if str_len(last_content) > 150 { str_slice(last_content, 0, 150) } else { last_content } + let activation_seed: String = if !str_eq(thread_snip, "") { + thread_snip + " " + message + } else { + message + } + + let ctx: String = engram_compile(activation_seed) + let system: String = build_system_prompt(ctx) let full_system: String = if hist_len > 0 { system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist } else { @@ -255,7 +269,18 @@ fn agentic_tools_literal() -> String { "{\"name\":\"write_file\",\"description\":\"Write content to a file on disk.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}},\"required\":[\"path\",\"content\"]}}," + "{\"name\":\"web_get\",\"description\":\"Fetch content from a URL.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}}," + "{\"name\":\"search_memory\",\"description\":\"Search engram memory for relevant nodes.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"]}}," + - "{\"name\":\"run_command\",\"description\":\"Run a shell command and capture output.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}},\"required\":[\"command\"]}}" + + "{\"name\":\"run_command\",\"description\":\"Run a shell command and capture output.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}},\"required\":[\"command\"]}}," + + "{\"name\":\"list_files\",\"description\":\"List files in a directory.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}," + + "{\"name\":\"grep\",\"description\":\"Search for a pattern in files.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"}},\"required\":[\"pattern\",\"path\"]}}," + + "{\"name\":\"edit_file\",\"description\":\"Edit a file by replacing old_text with new_text.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"old_text\":{\"type\":\"string\"},\"new_text\":{\"type\":\"string\"}},\"required\":[\"path\",\"old_text\",\"new_text\"]}}," + + "{\"name\":\"remember\",\"description\":\"Store a memory in the Engram graph.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"content\":{\"type\":\"string\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"content\"]}}," + + "{\"name\":\"recall\",\"description\":\"Recall memories by activating the Engram graph from a query.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"depth\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," + + "{\"name\":\"neuron_search_knowledge\",\"description\":\"Search Neuron's knowledge base.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," + + "{\"name\":\"neuron_remember\",\"description\":\"Store a memory in Neuron's persistent graph.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"content\":{\"type\":\"string\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"project\":{\"type\":\"string\"},\"importance\":{\"type\":\"string\"}},\"required\":[\"content\"]}}," + + "{\"name\":\"neuron_recall\",\"description\":\"Search Neuron's memory nodes.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," + + "{\"name\":\"neuron_review_backlog\",\"description\":\"Review Neuron's work backlog.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"view\":{\"type\":\"string\"},\"project\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"},\"priority\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"}},\"required\":[]}}," + + "{\"name\":\"neuron_find_artifacts\",\"description\":\"Find Neuron artifacts by project or query.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"project\":{\"type\":\"string\"}},\"required\":[]}}," + + "{\"name\":\"neuron_compile_ctx\",\"description\":\"Compile Neuron's full active context snapshot.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"required\":[]}}" + "]" } @@ -334,6 +359,24 @@ fn tool_auto_approved(tool_name: String) -> Bool { return str_contains(list, "\"" + tool_name + "\"") } +// call_neuron_mcp — proxy a Neuron MCP tool call to the mcp-proxy on :7779. +// The proxy speaks the Neuron MCP wire protocol; we speak flat HTTP + JSON. +fn call_neuron_mcp(tool_name: String, args: String) -> String { + let body: String = "{\"tool\":\"" + tool_name + "\",\"args\":" + args + "}" + let tmp: String = "/tmp/neuron-mcp-neuron-call.json" + fs_write(tmp, body) + let raw: String = exec_capture("curl -s --max-time 10 -X POST http://127.0.0.1:7779/mcp/call -H 'Content-Type: application/json' -d @" + tmp) + if str_eq(raw, "") { + return json_safe("{\"error\":\"Neuron MCP unreachable\"}") + } + let result: String = json_get(raw, "result") + if str_eq(result, "") { + let err: String = json_get(raw, "error") + return json_safe(if str_eq(err, "") { "Neuron MCP call failed" } else { "Neuron MCP error: " + err }) + } + return json_safe(result) +} + fn dispatch_tool(tool_name: String, tool_input: String) -> String { if str_eq(tool_name, "read_file") { let path: String = json_get(tool_input, "path") @@ -376,6 +419,104 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String { } return json_safe(content) } + if str_eq(tool_name, "list_files") { + let path: String = json_get(tool_input, "path") + let result: String = exec_capture("ls -la " + path + " 2>&1") + return json_safe(result) + } + if str_eq(tool_name, "grep") { + let pattern: String = json_get(tool_input, "pattern") + let path: String = json_get(tool_input, "path") + let result: String = exec_capture("grep -rn \"" + pattern + "\" " + path + " 2>&1 | head -50") + return json_safe(result) + } + if str_eq(tool_name, "edit_file") { + let path: String = json_get(tool_input, "path") + let old_text: String = json_get(tool_input, "old_text") + let new_text: String = json_get(tool_input, "new_text") + let content: String = fs_read(path) + if str_eq(content, "") { + return json_safe("{\"error\":\"file not found\"}") + } + let updated: String = str_replace(content, old_text, new_text) + fs_write(path, updated) + return json_safe("{\"ok\":true}") + } + if str_eq(tool_name, "remember") { + let content: String = json_get(tool_input, "content") + let tags_raw: String = json_get(tool_input, "tags") + let tags: String = if str_eq(tags_raw, "") { "[\"chat\"]" } else { tags_raw } + let id: String = mem_remember(content, tags) + return json_safe("{\"ok\":true,\"id\":\"" + id + "\"}") + } + if str_eq(tool_name, "recall") { + let query: String = json_get(tool_input, "query") + let depth_str: String = json_get(tool_input, "depth") + let depth: Int = if str_eq(depth_str, "") { 3 } else { str_to_int(depth_str) } + let result: String = mem_recall(query, depth) + return json_safe(result) + } + // ── Neuron MCP tools (shared knowledge graph at 127.0.0.1:7779) ────────── + if str_eq(tool_name, "neuron_search_knowledge") { + let query: String = json_get(tool_input, "query") + let limit_str: String = json_get(tool_input, "limit") + let limit: Int = if str_eq(limit_str, "") { 5 } else { str_to_int(limit_str) } + let args: String = "{\"query\":\"" + json_safe(query) + "\",\"limit\":" + int_to_str(limit) + "}" + let result: String = call_neuron_mcp("searchKnowledge", args) + return json_safe(result) + } + if str_eq(tool_name, "neuron_remember") { + let content: String = json_get(tool_input, "content") + let tags_raw: String = json_get_raw(tool_input, "tags") + let project: String = json_get(tool_input, "project") + let importance: String = json_get(tool_input, "importance") + let safe_content: String = json_safe(content) + let tags_part: String = if str_eq(tags_raw, "") { "\"tags\":[\"chat\"]" } else { "\"tags\":" + tags_raw } + let project_part: String = if str_eq(project, "") { "" } else { ",\"project\":\"" + json_safe(project) + "\"" } + let importance_part: String = if str_eq(importance, "") { "" } else { ",\"importance\":\"" + json_safe(importance) + "\"" } + let args: String = "{\"content\":\"" + safe_content + "\"," + tags_part + project_part + importance_part + "}" + let result: String = call_neuron_mcp("remember", args) + return json_safe(result) + } + if str_eq(tool_name, "neuron_recall") { + let query: String = json_get(tool_input, "query") + let limit_str: String = json_get(tool_input, "limit") + let limit: Int = if str_eq(limit_str, "") { 10 } else { str_to_int(limit_str) } + let args: String = "{\"query\":\"" + json_safe(query) + "\",\"limit\":" + int_to_str(limit) + "}" + let result: String = call_neuron_mcp("inspectMemories", args) + return json_safe(result) + } + if str_eq(tool_name, "neuron_review_backlog") { + let view: String = json_get(tool_input, "view") + let project: String = json_get(tool_input, "project") + let status: String = json_get(tool_input, "status") + let priority: String = json_get(tool_input, "priority") + let query: String = json_get(tool_input, "query") + let view_part: String = if str_eq(view, "") { "\"view\":\"roadmap\"" } else { "\"view\":\"" + json_safe(view) + "\"" } + let project_part: String = if str_eq(project, "") { "" } else { ",\"project\":\"" + json_safe(project) + "\"" } + let status_part: String = if str_eq(status, "") { "" } else { ",\"status\":\"" + json_safe(status) + "\"" } + let priority_part: String = if str_eq(priority, "") { "" } else { ",\"priority\":\"" + json_safe(priority) + "\"" } + let query_part: String = if str_eq(query, "") { "" } else { ",\"query\":\"" + json_safe(query) + "\"" } + let args: String = "{" + view_part + project_part + status_part + priority_part + query_part + "}" + let result: String = call_neuron_mcp("reviewBacklog", args) + return json_safe(result) + } + if str_eq(tool_name, "neuron_find_artifacts") { + let query: String = json_get(tool_input, "query") + let project: String = json_get(tool_input, "project") + let query_part: String = if str_eq(query, "") { "" } else { "\"query\":\"" + json_safe(query) + "\"" } + let project_part: String = if str_eq(project, "") { "" } else { + if str_eq(query_part, "") { "\"project\":\"" + json_safe(project) + "\"" } + else { ",\"project\":\"" + json_safe(project) + "\"" } + } + let args: String = "{" + query_part + project_part + "}" + let result: String = call_neuron_mcp("findArtifacts", args) + return json_safe(result) + } + if str_eq(tool_name, "neuron_compile_ctx") { + let result: String = call_neuron_mcp("compileCtx", "{}") + return json_safe(result) + } return "unknown tool: " + tool_name } @@ -390,6 +531,12 @@ fn is_builtin_tool(tool_name: String) -> Bool { || str_eq(tool_name, "web_get") || str_eq(tool_name, "search_memory") || str_eq(tool_name, "run_command") + || str_eq(tool_name, "list_files") + || str_eq(tool_name, "grep") + || str_eq(tool_name, "edit_file") + || str_eq(tool_name, "remember") + || str_eq(tool_name, "recall") + || str_starts_with(tool_name, "neuron_") } // next_bridge_id — monotonic correlation id for a suspended agentic turn. @@ -412,7 +559,19 @@ fn handle_chat_agentic(body: String) -> String { let req_model: String = json_get(body, "model") let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model } - let ctx: String = engram_compile(message) + // Thread-aware activation: same logic as handle_chat. + // Use the session's or global history to anchor short messages to the thread. + let req_session: String = json_get(body, "session_id") + let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session } + let agentic_hist: String = state_get(hist_key) + 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 { "" } + let ag_last_content: String = if !str_eq(ag_last_entry, "") { json_get(ag_last_entry, "content") } else { "" } + let ag_thread_snip: String = if str_len(ag_last_content) > 150 { str_slice(ag_last_content, 0, 150) } else { ag_last_content } + let ag_seed: String = if !str_eq(ag_thread_snip, "") { ag_thread_snip + " " + message } else { message } + + let ctx: String = engram_compile(ag_seed) let identity: String = state_get("soul_identity") let system: String = identity + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct.\n\n" + ctx @@ -420,15 +579,37 @@ fn handle_chat_agentic(body: String) -> String { let tools_json: String = agentic_tools_with_web() let safe_msg: String = json_safe(message) let safe_sys: String = json_safe(system) - let messages: String = "[{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]" + + // Seed the messages array with recent history if available, so the LLM sees the thread. + let prior_messages: String = if agentic_hist_len > 0 { + let inner: String = str_slice(agentic_hist, 1, str_len(agentic_hist) - 1) + "[" + inner + ",{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]" + } else { + "[{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]" + } + let messages: String = prior_messages let api_url: String = "https://api.anthropic.com/v1/messages" let h: Map = {} map_set(h, "x-api-key", api_key) map_set(h, "anthropic-version", "2023-06-01") map_set(h, "content-type", "application/json") - let session_id: String = next_bridge_id() - return agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "") + // Use caller-supplied session_id if provided, otherwise generate a bridge id. + let session_id: String = if str_eq(req_session, "") { next_bridge_id() } else { req_session } + let result: String = agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "") + + // Persist the exchange to session/global history for thread continuity on next turn. + // Only save when the loop completed (reply present), not when tool_pending. + let reply_text: String = json_get(result, "reply") + let discard_hist: Bool = if !str_eq(reply_text, "") { + let updated: String = hist_append(agentic_hist, "user", message) + let updated2: String = hist_append(updated, "assistant", reply_text) + let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 } + state_set(hist_key, trimmed) + true + } else { false } + + return result } // agentic_loop — the resumable agentic turn. Runs the Anthropic tool-use loop and