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_exists — return true if the given session_id is known in Engram or state.
// Used by chat.el to validate a session_id before processing a chat message.
// Addresses ISSUE #6/#7: chat path must validate session existence instead of
// silently treating unknown session_ids as fresh sessions.
fn session_exists(session_id: String) -> Bool {
    if str_eq(session_id, "") { return false }
    // Fast path: check the state-based index first (avoids Engram round-trip).
    let idx: String = state_get("session_index")
    if !str_eq(idx, "") && !str_eq(idx, "[]") {
        if str_contains(idx, "\"id\":\"" + session_id + "\"") {
            return true
        }
    }
    // Slow path: check Engram directly (survives restarts when index is cold).
    let results: String = engram_search_json("session:meta " + session_id, 5)
    if str_eq(results, "") { return false }
    if str_eq(results, "[]") { return false }
    let total: Int = json_array_len(results)
    let found: Bool = false
    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 found = if is_match { true } else { found }
        let i = i + 1
    }
    return found
}

// session_create — create a new session, return {id, title, created_at}.
//
// ISSUE #1: Ghost sessions on failed first message.
// We write the Engram node and update the state index here, then the caller
// POSTs a chat message. If that chat call fails (LLM unavailable, network
// error, etc.) the session is stranded with no messages. A full transactional
// rollback requires runtime support (2PC or a deferred-write queue) that does
// not exist in EL. Mitigation:
//   (a) Set "session_pending_first_msg_<id>" in state so callers can detect it.
//   (b) Provide session_create_cleanup() for callers that detect a failure.
// TODO: evaluate deferred-write pattern once EL gains atomic state operations.
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)
    // Mark as pending first message so stale ghost sessions can be identified
    // (e.g. if the caller\'s subsequent chat POST fails).
    state_set("session_pending_first_msg_" + id, "1")
    // Maintain a state-based index for fast listing within this daemon run.
    // Newest sessions first (prepend).
    // TODO #4: index update is read-modify-write — two concurrent session_create
    // calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
    // TODO(reliability #2): session_index RMW is non-atomic. Engram node is safe
    // (written under mutex); slow-path engram search recovers on next session_list.
    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_create_cleanup — undo a session_create when the caller\'s first chat
// fails. Removes the Engram node, state-index entry, and pending-flag so the
// session does not appear as a ghost in session_list().
// Addresses ISSUE #1: cleanup path for ghost sessions.
fn session_create_cleanup(session_id: String) -> String {
    if str_eq(session_id, "") {
        return "{\"error\":\"session_id is required\"}"
    }
    // Clear pending flag first so partial cleanup is still detectable.
    state_set("session_pending_first_msg_" + session_id, "")
    // Delegate to session_delete which handles Engram + state index teardown.
    return session_delete(session_id)
}

// 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", "")
    // ISSUE #5: clean up bridge blobs and always_allow keys that were never
    // cleared by agentic_resume (e.g. client abandoned a pending tool call).
    // Without this, stranded bridge blobs accumulate indefinitely in state.
    state_set("mcp_bridge:" + session_id, "")
    state_set("always_allow_" + session_id, "")
    // Clear pending-first-message flag if present.
    state_set("session_pending_first_msg_" + session_id, "")
    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.
//
// ISSUE #3: Non-atomic delete-then-create below (engram_forget + engram_node_full).
// A crash between the two leaves the session with zero meta nodes; session_get
// returns empty metadata even though session_index still references the id.
// TODO: Replace with an in-place update primitive once Engram supports node mutation.
// Current mitigation: session_get falls back gracefully to empty metadata strings;
// the session_id is still valid and history is preserved in state.
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_entry — extract one search-result entry from a raw node JSON.
// Returns a JSON object string or "" if the node is not a valid session:meta node.
//
// Extracted from session_search's while loop body to reduce the loop's lexical
// complexity. The ELC compiler runs out of memory processing while loops with
// many `let` bindings — extracting the body into a separate function gives the
// compiler a clean scope boundary at each call. Each function compiles in O(N)
// rather than the exponential growth caused by rebinding accumulation inside loops.
// (2026-07-01 self-review: root cause of sessions.c OOM/truncation since June 30)
fn session_search_entry(node: String) -> String {
    let label: String = json_get(node, "label")
    if !str_eq(label, "session:meta") { return "" }
    let content: String = json_get(node, "content")
    let sess_id: String = json_get(content, "id")
    if str_eq(sess_id, "") { return "" }
    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 e_id: String = "{\"id\":\"" + json_safe(sess_id) + "\""
    let e_title: String = ",\"title\":\"" + json_safe(title) + "\""
    let e_ts: String = ",\"created_at\":" + eff_created + ",\"updated_at\":" + eff_updated + "}"
    return e_id + e_title + e_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 entry: String = session_search_entry(json_array_get(results, i))
        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)
    // Clear pending-first-message flag: once history is saved, the session
    // is no longer in the ghost/pending state (ISSUE #1 mitigation).
    state_set("session_pending_first_msg_" + session_id, "")
    // 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
    }
    // TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
    // same session can produce orphan history nodes. State is primary truth; engram fallback.
    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 boundary emotional summary — written once per session the first time
    // a bell event has fired. The summary node is findable by future sessions via
    // broad affective queries ("session:emotional-summary" or "bell distress session").
    // It is NOT rewritten on every save — the state flag prevents duplicate nodes.
    let summary_written_key: String = "session_bell_summary_written:" + session_id
    let already_written: String = state_get(summary_written_key)
    if str_eq(already_written, "") {
        let bell_count_key: String = "session_bell_count:" + session_id
        let bell_count_raw: String = state_get(bell_count_key)
        let bell_count: Int = if str_eq(bell_count_raw, "") { 0 } else { str_to_int(bell_count_raw) }
        if bell_count > 0 {
            let bell_level_key: String = "session_bell_level:" + session_id
            let bell_signal_key: String = "session_bell_signal:" + session_id
            let dominant_level: String = state_get(bell_level_key)
            let last_signal: String = state_get(bell_signal_key)
            let eff_level: String = if str_eq(dominant_level, "") { "soft" } else { dominant_level }
            let eff_signal: String = if str_eq(last_signal, "") { "(no signal captured)" } else { last_signal }
            let ts_now: Int = time_now()
            let summary_content: String = "session:emotional-summary"
                + " | session:" + session_id
                + " | bell_count:" + int_to_str(bell_count)
                + " | dominant_level:" + eff_level
                + " | last_signal:" + eff_signal
                + " | ts:" + int_to_str(ts_now)
            let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]"
            let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) }
            let sum_discard: String = engram_node_full(
                summary_content,
                "BellEvent",
                "session:emotional-summary",
                summary_sal,
                summary_sal,
                el_from_float(1.0),
                "Episodic",
                summary_tags
            )
            // Mark written so we do not create duplicate summary nodes as the
            // session continues accumulating more turns.
            state_set(summary_written_key, "1")
        }
    }

    // Issue 5 fix: write a last-session-topic Conversation node so future sessions can
    // find the most recent session's topic via engram search. This enables cross-session
    // continuity — chat.el searches for "last-session-topic" and shows a [CONTINUING FROM
    // LAST SESSION] section on the first message of a new session.
    let hist_arr_len: Int = if str_eq(hist, "") { 0 } else { json_array_len(hist) }
    if hist_arr_len >= 2 {
        let last_entry: String = json_array_get(hist, hist_arr_len - 1)
        let last_role: String = json_get(last_entry, "role")
        let last_content: String = json_get(last_entry, "content")
        let topic_snip: String = if str_len(last_content) > 200 { str_slice(last_content, 0, 200) } else { last_content }
        let safe_topic: String = str_replace(topic_snip, "\"", "'")
        let ts_now: String = int_to_str(time_now())
        let topic_content: String = "last-session-topic | ts:" + ts_now + " | session:" + session_id + " | topic:" + safe_topic
        let topic_tags: String = "[\"last-session-topic\",\"conv:history\",\"Conversation\",\"session:topic\"]"
        let topic_label: String = "last-session-topic:" + session_id
        // Delete old last-session-topic node for this session before writing fresh
        let old_topic: String = engram_search_json("last-session-topic:" + session_id, 2)
        let ot_len: Int = if str_eq(old_topic, "") { 0 } else { json_array_len(old_topic) }
        let oti: Int = 0
        while oti < ot_len {
            let ot_node: String = json_array_get(old_topic, oti)
            let ot_id: String = json_get(ot_node, "id")
            if !str_eq(ot_id, "") { engram_forget(ot_id) }
            let oti = oti + 1
        }
        let discard_topic: String = engram_node_full(
            topic_content, "Conversation", topic_label,
            el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
            "Episodic", topic_tags
        )
    }
}

// session_update_meta_timestamp — update the updated_at field in the session:meta node.
//
// ISSUE #2: No TTL / idle expiry mechanism. Sessions accumulate indefinitely.
// A sweep job (e.g. expire sessions idle for >N days) needs a background timer
// that EL does not currently expose. Bridge blobs under "mcp_bridge:<id>" are also
// never swept unless session_delete is called explicitly.
// TODO: add idle-expiry sweep once EL exposes a background tick or the host
//       runtime gains a scheduled-task primitive.
//
// ISSUE #3 applies here too: delete-then-create is non-atomic. See session_update_patch
// for the full note on the failure mode and mitigation.
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.
//
// ISSUE #8: Reconnect/duplicate resume race. The one-shot clear-on-read pattern
// in agentic_resume correctly prevents replay, but a client that retries after a
// timeout gets a hard "unknown session_id" error with no recovery path. The
// conversation is permanently stuck in that case. Full idempotency (e.g. caching
// the last reply keyed by call_id) requires a new state structure.
// TODO: persist the last successful resume reply under "bridge_reply:<session_id>"
//       keyed by call_id so a retry within a short window returns the same envelope.
//
// Modern path (agentic_loop / bridge): the loop saves its suspension to
// "mcp_bridge:<session_id>" 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_<session_id>): 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:<session_id> ──────────────
    // 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_<session_id> ────────────
    // 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)
}
