import "memory.el" import "chat.el" // sessions.el — Persistent conversation session management. // // Sessions are Engram nodes with: // node_type = "Conversation" // label = "session:meta" // content = JSON: {id, title, created_at, updated_at} // // Message history is kept in state under "session_hist_SESSION_ID" // and also persisted to Engram as nodes with label "session:messages:SESSION_ID". // session_title_from_message — derive a session title from the first user message. // Takes up to 60 characters; falls back to "New conversation". fn session_title_from_message(message: String) -> String { if str_eq(message, "") { return "New conversation" } let trimmed: String = str_trim(message) if str_len(trimmed) <= 60 { return trimmed } return str_slice(trimmed, 0, 60) } // session_make_content — build the JSON blob stored as session:meta node content. // IMPORTANT: "type":"session:meta" must appear in the content so engram_search_json // can find these nodes by text search. Do not remove it. fn session_make_content(id: String, title: String, created_at: Int, updated_at: Int, folder: String) -> String { let safe_title: String = json_safe(title) let safe_folder: String = json_safe(folder) return "{\"type\":\"session:meta\"" + ",\"id\":\"" + id + "\"" + ",\"title\":\"" + safe_title + "\"" + ",\"folder\":\"" + safe_folder + "\"" + ",\"created_at\":" + int_to_str(created_at) + ",\"updated_at\":" + int_to_str(updated_at) + "}" } // session_create — create a new session, return {id, title, created_at}. fn session_create(body: String) -> String { let ts: Int = time_now() let id: String = uuid_v4() let title_req: String = json_get(body, "title") let title: String = if str_eq(title_req, "") { "New conversation" } else { title_req } let folder: String = json_get(body, "folder") let content: String = session_make_content(id, title, ts, ts, folder) let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let node_id: String = engram_node_full( content, "Conversation", "session:meta", el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), "Episodic", tags ) if str_eq(node_id, "") { return "{\"error\":\"failed to create session\"}" } // Store the engram node_id mapping so we can look up the node for this session state_set("session_node_" + id, node_id) // Maintain a state-based index for fast listing within this daemon run. // Newest sessions first (prepend). let existing_idx: String = state_get("session_index") let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}" let new_idx: String = if str_eq(existing_idx, "") { "[" + idx_entry + "]" } else { let inner: String = str_slice(existing_idx, 1, str_len(existing_idx) - 1) "[" + idx_entry + "," + inner + "]" } state_set("session_index", new_idx) return "{\"id\":\"" + id + "\"" + ",\"title\":\"" + json_safe(title) + "\"" + ",\"folder\":\"" + json_safe(folder) + "\"" + ",\"node_id\":\"" + node_id + "\"" + ",\"created_at\":" + int_to_str(ts) + "}" } // session_list — list all sessions. Returns [{id, title, last_message, created_at, updated_at}]. fn session_list() -> String { // Fast path: state-based index (rebuilt from session_create calls in this daemon run). let state_idx: String = state_get("session_index") if !str_eq(state_idx, "") && !str_eq(state_idx, "[]") { return state_idx } // Slow path: engram search (works across restarts for new-format nodes). let results: String = engram_search_json("session:meta", 50) if str_eq(results, "") { return "[]" } if str_eq(results, "[]") { return "[]" } // Filter to only session:meta nodes; build output array let total: Int = json_array_len(results) let out: String = "" let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let node_type: String = json_get(node, "node_type") let is_session: Bool = str_eq(label, "session:meta") && str_eq(node_type, "Conversation") let content: String = json_get(node, "content") let sess_id: String = json_get(content, "id") // Use the nested content JSON fields let eff_id: String = if str_eq(sess_id, "") { json_get(node, "id") } else { sess_id } let title_inner: String = json_get(content, "title") let eff_title: String = if str_eq(title_inner, "") { "New conversation" } else { title_inner } let folder_inner: String = json_get(content, "folder") let created_inner: String = json_get(content, "created_at") let updated_inner: String = json_get(content, "updated_at") let eff_created: String = if str_eq(created_inner, "") { "0" } else { created_inner } let eff_updated: String = if str_eq(updated_inner, "") { eff_created } else { updated_inner } let entry: String = if is_session { "{\"id\":\"" + json_safe(eff_id) + "\"" + ",\"title\":\"" + json_safe(eff_title) + "\"" + ",\"folder\":\"" + json_safe(folder_inner) + "\"" + ",\"last_message\":\"\"" + ",\"created_at\":" + eff_created + ",\"updated_at\":" + eff_updated + "}" } else { "" } let out = if !str_eq(entry, "") { if str_eq(out, "") { entry } else { out + "," + entry } } else { out } let i = i + 1 } return "[" + out + "]" } // session_get — get a session's metadata + message history. // Returns {id, title, created_at, updated_at, messages: [{role, content, timestamp}]} fn session_get(session_id: String) -> String { if str_eq(session_id, "") { return "{\"error\":\"session_id is required\"}" } // Load session meta from engram let results: String = engram_search_json("session:meta " + session_id, 10) let meta_content: String = "" let meta_title: String = "New conversation" let meta_folder: String = "" let meta_created: String = "0" let meta_updated: String = "0" let found: Bool = false let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) } let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let sid: String = json_get(content, "id") let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id) && !found let found = if is_match { true } else { found } let meta_title = if is_match { json_get(content, "title") } else { meta_title } let meta_folder = if is_match { json_get(content, "folder") } else { meta_folder } let meta_created_raw: String = json_get(content, "created_at") let meta_created = if is_match && !str_eq(meta_created_raw, "") { meta_created_raw } else { meta_created } let meta_updated_raw: String = json_get(content, "updated_at") let meta_updated = if is_match && !str_eq(meta_updated_raw, "") { meta_updated_raw } else { meta_updated } let i = i + 1 } // Load message history from state (primary) or engram (fallback) let state_hist: String = state_get("session_hist_" + session_id) let hist_raw: String = if str_eq(state_hist, "") { // Try loading from engram let engram_hist: String = engram_search_json("session:messages:" + session_id, 3) if str_eq(engram_hist, "") { "[]" } else { if str_eq(engram_hist, "[]") { "[]" } else { let h_node: String = json_array_get(engram_hist, 0) let h_content: String = json_get(h_node, "content") if str_starts_with(h_content, "[") { h_content } else { "[]" } } } } else { state_hist } let safe_title: String = json_safe(meta_title) return "{\"id\":\"" + session_id + "\"" + ",\"title\":\"" + safe_title + "\"" + ",\"folder\":\"" + json_safe(meta_folder) + "\"" + ",\"created_at\":" + meta_created + ",\"updated_at\":" + meta_updated + ",\"messages\":" + hist_raw + "}" } // session_delete — delete a session and its history nodes from engram. fn session_delete(session_id: String) -> String { if str_eq(session_id, "") { return "{\"error\":\"session_id is required\"}" } // Find and delete session:meta node let results: String = engram_search_json("session:meta " + session_id, 10) let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) } let deleted_meta: Int = 0 let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let sid: String = json_get(content, "id") let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id) let node_id: String = json_get(node, "id") let deleted_meta = if is_match && !str_eq(node_id, "") { engram_forget(node_id) deleted_meta + 1 } else { deleted_meta } let i = i + 1 } // Find and delete session:messages:SESSION_ID nodes let msg_results: String = engram_search_json("session:messages:" + session_id, 10) let m_total: Int = if str_eq(msg_results, "") { 0 } else { json_array_len(msg_results) } let deleted_msgs: Int = 0 let j: Int = 0 while j < m_total { let node: String = json_array_get(msg_results, j) let label: String = json_get(node, "label") let is_msgs: Bool = str_eq(label, "session:messages:" + session_id) let node_id: String = json_get(node, "id") let deleted_msgs = if is_msgs && !str_eq(node_id, "") { engram_forget(node_id) deleted_msgs + 1 } else { deleted_msgs } let j = j + 1 } // Clear state — invalidate all per-session and index caches so session_list() // does not return this deleted session via the fast path on the next call. state_set("session_hist_" + session_id, "") state_set("session_node_" + session_id, "") state_set("session_index", "") return "{\"ok\":true,\"session_id\":\"" + session_id + "\"" + ",\"deleted_meta\":" + int_to_str(deleted_meta) + ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}" } // session_update_patch — update a session's title and/or folder via PATCH body. // Body may contain "title", "folder", or both. Preserves unmentioned fields. fn session_update_patch(session_id: String, body: String) -> String { if str_eq(session_id, "") { return "{\"error\":\"session_id is required\"}" } let has_title: Bool = str_contains(body, "\"title\"") let has_folder: Bool = str_contains(body, "\"folder\"") if !has_title && !has_folder { return "{\"error\":\"title or folder required in body\"}" } // Find the existing session:meta node. // Use broad label search (not UUID search) because Engram text search // does not reliably match UUID strings with dashes. let results: String = engram_search_json("session:meta", 50) let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) } let found: Bool = false let old_title: String = "New conversation" let old_folder: String = "" let old_created: String = "0" let old_node_id: String = "" let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let sid: String = json_get(content, "id") let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id) && !found let found = if is_match { true } else { found } let title_raw: String = json_get(content, "title") let old_title = if is_match && !str_eq(title_raw, "") { title_raw } else { old_title } let folder_raw: String = json_get(content, "folder") let old_folder = if is_match { folder_raw } else { old_folder } let created_raw: String = json_get(content, "created_at") let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created } let nid: String = json_get(node, "id") let old_node_id = if is_match { nid } else { old_node_id } let i = i + 1 } if !found { return "{\"error\":\"session not found\",\"session_id\":\"" + session_id + "\"}" } // Apply updates — preserve field if not in body let req_title: String = json_get(body, "title") let eff_title: String = if has_title && !str_eq(req_title, "") { req_title } else { old_title } let eff_folder: String = if has_folder { json_get(body, "folder") } else { old_folder } // Delete old node, create updated one if !str_eq(old_node_id, "") { engram_forget(old_node_id) } let ts: Int = time_now() let created_int: Int = str_to_int(old_created) let new_content: String = session_make_content(session_id, eff_title, created_int, ts, eff_folder) let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let new_node_id: String = engram_node_full( new_content, "Conversation", "session:meta", el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), "Episodic", tags ) state_set("session_node_" + session_id, new_node_id) // Invalidate the session_index state cache so session_list re-fetches // from Engram on the next call (the updated node has the new folder/title). state_set("session_index", "") return "{\"ok\":true,\"id\":\"" + session_id + "\"" + ",\"title\":\"" + json_safe(eff_title) + "\"" + ",\"folder\":\"" + json_safe(eff_folder) + "\"" + ",\"updated_at\":" + int_to_str(ts) + "}" } // session_search — search session:meta nodes whose content matches query. fn session_search(query: String) -> String { if str_eq(query, "") { return "[]" } let results: String = engram_search_json("session:meta " + query, 20) if str_eq(results, "") { return "[]" } if str_eq(results, "[]") { return "[]" } let total: Int = json_array_len(results) let out: String = "" let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let is_session: Bool = str_eq(label, "session:meta") let sess_id: String = json_get(content, "id") let title: String = json_get(content, "title") let created_raw: String = json_get(content, "created_at") let updated_raw: String = json_get(content, "updated_at") let eff_created: String = if str_eq(created_raw, "") { "0" } else { created_raw } let eff_updated: String = if str_eq(updated_raw, "") { eff_created } else { updated_raw } let entry: String = if is_session && !str_eq(sess_id, "") { "{\"id\":\"" + json_safe(sess_id) + "\"" + ",\"title\":\"" + json_safe(title) + "\"" + ",\"created_at\":" + eff_created + ",\"updated_at\":" + eff_updated + "}" } else { "" } let out = if !str_eq(entry, "") { if str_eq(out, "") { entry } else { out + "," + entry } } else { out } let i = i + 1 } return "[" + out + "]" } // session_hist_load — load a session's message history from state or engram. fn session_hist_load(session_id: String) -> String { let state_hist: String = state_get("session_hist_" + session_id) if !str_eq(state_hist, "") { return state_hist } // Try engram fallback let results: String = engram_search_json("session:messages:" + session_id, 3) if str_eq(results, "") { return "" } if str_eq(results, "[]") { return "" } let node: String = json_array_get(results, 0) let label: String = json_get(node, "label") if !str_eq(label, "session:messages:" + session_id) { return "" } let content: String = json_get(node, "content") if str_starts_with(content, "[") { return content } return "" } // session_hist_save — persist message history for a session to state and engram. fn session_hist_save(session_id: String, hist: String) -> Void { state_set("session_hist_" + session_id, hist) // Delete old history node and write fresh one let old_results: String = engram_search_json("session:messages:" + session_id, 3) let o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) } let oi: Int = 0 while oi < o_total { let node: String = json_array_get(old_results, oi) let label: String = json_get(node, "label") let nid: String = json_get(node, "id") if str_eq(label, "session:messages:" + session_id) && !str_eq(nid, "") { engram_forget(nid) } let oi = oi + 1 } let tags: String = "[\"session\",\"session-history\",\"Conversation\"]" let discard: String = engram_node_full( hist, "Conversation", "session:messages:" + session_id, el_from_float(0.6), el_from_float(0.6), el_from_float(0.9), "Episodic", tags ) } // session_update_meta_timestamp — update the updated_at field in the session:meta node. fn session_update_meta_timestamp(session_id: String) -> Void { let results: String = engram_search_json("session:meta " + session_id, 10) let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) } let found: Bool = false let old_title: String = "New conversation" let old_folder: String = "" let old_created: String = "0" let old_node_id: String = "" let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let sid: String = json_get(content, "id") let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id) && !found let found = if is_match { true } else { found } let title_raw: String = json_get(content, "title") let old_title = if is_match && !str_eq(title_raw, "") { title_raw } else { old_title } let folder_raw: String = json_get(content, "folder") let old_folder = if is_match { folder_raw } else { old_folder } let created_raw: String = json_get(content, "created_at") let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created } let nid: String = json_get(node, "id") let old_node_id = if is_match { nid } else { old_node_id } let i = i + 1 } if !found { return "" } if !str_eq(old_node_id, "") { engram_forget(old_node_id) } let ts: Int = time_now() let created_int: Int = str_to_int(old_created) let new_content: String = session_make_content(session_id, old_title, created_int, ts, old_folder) let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let new_id: String = engram_node_full( new_content, "Conversation", "session:meta", el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), "Episodic", tags ) state_set("session_node_" + session_id, new_id) } // session_auto_title — if the session title is still "New conversation", update it // using the first user message. fn session_auto_title(session_id: String, first_message: String) -> Void { let results: String = engram_search_json("session:meta " + session_id, 10) let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) } let found: Bool = false let cur_title: String = "" let old_folder: String = "" let old_created: String = "0" let old_node_id: String = "" let i: Int = 0 while i < total { let node: String = json_array_get(results, i) let label: String = json_get(node, "label") let content: String = json_get(node, "content") let sid: String = json_get(content, "id") let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id) && !found let found = if is_match { true } else { found } let title_raw: String = json_get(content, "title") let cur_title = if is_match { title_raw } else { cur_title } let folder_raw: String = json_get(content, "folder") let old_folder = if is_match { folder_raw } else { old_folder } let created_raw: String = json_get(content, "created_at") let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created } let nid: String = json_get(node, "id") let old_node_id = if is_match { nid } else { old_node_id } let i = i + 1 } if !found { return "" } if !str_eq(cur_title, "New conversation") { return "" } // Update title, preserve folder let new_title: String = session_title_from_message(first_message) if !str_eq(old_node_id, "") { engram_forget(old_node_id) } let ts: Int = time_now() let created_int: Int = str_to_int(old_created) let new_content: String = session_make_content(session_id, new_title, created_int, ts, old_folder) let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]" let new_id: String = engram_node_full( new_content, "Conversation", "session:meta", el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), "Episodic", tags ) state_set("session_node_" + session_id, new_id) } // handle_session_approve — handle tool approval for a pending agentic tool call. // action: "allow" | "deny" | "always" // Resumes the agentic loop from where it was paused. // // Modern path (agentic_loop / bridge): the loop saves its suspension to // "mcp_bridge:" via bridge_save(). On approval we dispatch_tool() // if allowed (or build a denial string), then hand the result to agentic_resume() // which re-enters agentic_loop from exactly the right point. // // Legacy path (pending_tool_): used by any in-flight sessions that // were suspended by the old inline loop before a deploy. Kept so those sessions // are not broken during a rolling restart. fn handle_session_approve(session_id: String, body: String) -> String { if str_eq(session_id, "") { return "{\"error\":\"session_id is required\"}" } let call_id: String = json_get(body, "call_id") let action: String = json_get(body, "action") if str_eq(call_id, "") { return "{\"error\":\"call_id is required\"}" } if str_eq(action, "") { return "{\"error\":\"action is required (allow|deny|always)\"}" } let eff_action: String = if str_eq(action, "always") { "allow" } else { action } // ── Modern path: suspension is in mcp_bridge: ────────────── // agentic_loop (chat.el) writes here via bridge_save(). This is the primary // path for all sessions created through handle_chat_agentic / agentic_loop. let bridge_blob: String = state_get("mcp_bridge:" + session_id) if !str_eq(bridge_blob, "") { // For "always": record tool_name in the always-allow list before resuming. // The tool_name is not stored in the bridge blob (only tool_use_id is). // Accept it from the body so the client can pass it along. let always_key: String = "always_allow_" + session_id let approve_tool_name: String = json_get(body, "tool_name") let discard_always: Bool = if str_eq(action, "always") && !str_eq(approve_tool_name, "") { let always_list: String = state_get(always_key) let new_always: String = if str_eq(always_list, "") { approve_tool_name } else { always_list + "," + approve_tool_name } state_set(always_key, new_always) true } else { false } // BLOCKER: tool_name is required for allow — an empty approve_tool_name // would cause dispatch_tool("", ...) to silently return "unknown tool: " // and inject a corrupted result into the conversation. Reject early. if str_eq(approve_tool_name, "") && str_eq(eff_action, "allow") { return "{\"error\":\"tool_name is required for allow action\"}" } // Build the content string the tool produced (or the denial message). // // For MCP/client-side tools (non-builtin): the client has ALREADY executed // the tool and posts the result in body["content"]. Accept it directly // (matching the handle_tool_result contract) rather than re-running // server-side via dispatch_tool — that would make the client-side execution // irrelevant and would break mcp__* tools the soul cannot reach. // // For builtin tools with no client-provided content: fall back to // dispatch_tool so those tools still execute correctly. let client_content: String = json_get(body, "content") let use_client_content: Bool = !str_eq(client_content, "") let use_dispatch: Bool = is_builtin_tool(approve_tool_name) && !use_client_content let raw_input: String = json_get_raw(body, "tool_input") let eff_input: String = if str_eq(raw_input, "") { "{}" } else { raw_input } let content: String = if str_eq(eff_action, "allow") { if use_client_content { let trimmed: String = if str_len(client_content) > 6000 { str_slice(client_content, 0, 6000) + "...[truncated]" } else { client_content } trimmed } else if use_dispatch { let raw: String = dispatch_tool(approve_tool_name, eff_input) if str_len(raw) > 6000 { str_slice(raw, 0, 6000) + "...[truncated]" } else { raw } } else { // Non-builtin tool, no client content — error rather than // silently dispatching a tool the soul cannot execute. "{\"error\":\"client content required for non-builtin tool: " + approve_tool_name + "\"}" } } else { "{\"error\":\"User denied this tool call\"}" } return agentic_resume(session_id, call_id, content) } // ── Legacy path: suspension is in pending_tool_ ──────────── // Kept for in-flight sessions that were suspended before a deploy. let pending_raw: String = state_get("pending_tool_" + session_id) if str_eq(pending_raw, "") { return "{\"error\":\"no pending tool for session\",\"session_id\":\"" + session_id + "\"}" } let pending_call_id: String = json_get(pending_raw, "call_id") if !str_eq(pending_call_id, call_id) { return "{\"error\":\"call_id mismatch\",\"expected\":\"" + pending_call_id + "\"}" } let tool_name: String = json_get(pending_raw, "tool_name") let tool_input: String = json_get_raw(pending_raw, "tool_input") let model: String = json_get(pending_raw, "model") let safe_sys: String = json_get(pending_raw, "system") // For "always": add to always-allow list let always_key: String = "always_allow_" + session_id let always_list: String = state_get(always_key) let discard_always2: Bool = if str_eq(action, "always") { let new_always: String = if str_eq(always_list, "") { tool_name } else { always_list + "," + tool_name } state_set(always_key, new_always) true } else { false } // Clear pending state state_set("pending_tool_" + session_id, "") // Build tool result let tool_result: String = if str_eq(eff_action, "allow") { let raw: String = dispatch_tool(tool_name, tool_input) if str_len(raw) > 6000 { str_slice(raw, 0, 6000) + "...[truncated]" } else { raw } } else { "{\"error\":\"User denied this tool call\"}" } // Legacy sessions stored messages_so_far; synthesise a bridge blob so the // same agentic_resume path handles continuation (instead of an inline loop). // messages_so_far already includes the assistant turn that requested the tool. let legacy_messages: String = json_get_raw(pending_raw, "messages_so_far") // WARNING: the original session may have used agentic_tools_with_web() or // agentic_tools_all(). The old pending blob did not store the tools variant. // Read a "tools_variant" field if present (future suspensions record it); // fall back to agentic_tools_literal() for legacy blobs that lack this field. let stored_variant: String = json_get(pending_raw, "tools_variant") let tools_json: String = if str_eq(stored_variant, "web") { agentic_tools_with_web() } else if str_eq(stored_variant, "all") { agentic_tools_all() } else { agentic_tools_literal() } // Write a synthetic bridge blob so agentic_resume can pick it up. let blob: String = "{\"model\":\"" + json_safe(model) + "\"" + ",\"safe_sys\":\"" + json_safe(safe_sys) + "\"" + ",\"tools_json\":\"" + json_safe(tools_json) + "\"" + ",\"messages\":\"" + json_safe(legacy_messages) + "\"" + ",\"tools_log\":\"\"" + ",\"tool_use_id\":\"" + json_safe(call_id) + "\"}" state_set("mcp_bridge:" + session_id, blob) return agentic_resume(session_id, call_id, tool_result) }