Compare commits

..

2 Commits

Author SHA1 Message Date
will.anderson f0545defdb fix(reliability): session-boundary — ghost sessions, bridge leak, session validation
Neuron Soul CI / build (pull_request) Has been cancelled
- sessions.el: add session_exists() for chat-path session validation (ISSUE #6/#7)
- sessions.el: add session_create_cleanup() for ghost-session rollback (ISSUE #1)
- sessions.el: set session_pending_first_msg flag in session_create; clear it in
  session_hist_save so the first successful chat marks the session active (ISSUE #1)
- sessions.el: session_delete now clears mcp_bridge:<id> and always_allow_<id>
  state keys so abandoned pending-tool sessions do not accumulate (ISSUE #5)
- sessions.el: add TODO comments for ISSUE #2 (no TTL/expiry), ISSUE #3
  (non-atomic delete-then-create), ISSUE #4 (no concurrent-create guard),
  and ISSUE #8 (reconnect/duplicate resume race) where fixes are too invasive
  to land without new runtime primitives
- chat.el: validate session_id exists via session_exists() before entering
  agentic_loop; unknown session_ids now return a 404-style error instead of
  silently starting a fresh empty session (ISSUE #6/#7)
2026-06-22 11:58:33 -05:00
Tim Lingo 1b83b18c39 propose(agentic): read agent_workspace_root from request body and persist to state
Neuron Soul CI / build (pull_request) Successful in 7m45s
Completes the UI<->soul contract for #23 (scope file/command tools to an agent
workspace root). #23 made the tools read state_get("agent_workspace_root"), but
nothing set that key from the desktop UI, so the agent panel's Workspace Folder
was cosmetic and tools ran unscoped (default-allow). This reads the root the UI
now sends on each agentic request and state_sets it before tool dispatch, so
agent_workspace_root() picks it up for the turn.

Minimal + pattern-matching (same json_get/state_set shape used throughout chat.el).
Empty body field => unscoped (backward-compatible) and preserves the env fallback.

FOR WILL'S REVIEW — do not merge without sign-off:
- Ownership model: set state from body each turn (so clearing the folder un-scopes)
  vs. only-when-nonempty. Flagged inline.
- Pairs with neuron-ui PR #32 (ChatRequest.agentWorkspaceRoot).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:56:20 -05:00
6 changed files with 120 additions and 50 deletions
+1 -4
View File
@@ -23,14 +23,11 @@ fn ise_post(content: String) -> Void {
let ise_url: String = env("SOUL_ISE_URL") let ise_url: String = env("SOUL_ISE_URL")
let engram_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url } let engram_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url }
if str_eq(engram_url, "") { if str_eq(engram_url, "") {
let local_id: String = engram_node_full( let discard: String = engram_node_full(
content, "InternalStateEvent", "state-event", content, "InternalStateEvent", "state-event",
el_from_float(0.3), el_from_float(0.3), el_from_float(0.8), el_from_float(0.3), el_from_float(0.3), el_from_float(0.8),
"Episodic", "[\"internal-state\",\"InternalStateEvent\"]" "Episodic", "[\"internal-state\",\"InternalStateEvent\"]"
) )
if str_eq(local_id, "") {
println("[awareness] ise_post: local engram_node_full failed — ISE lost")
}
return "" return ""
} }
// Proper JSON string escaping: backslashes first, then quotes, then control chars. // Proper JSON string escaping: backslashes first, then quotes, then control chars.
+16 -17
View File
@@ -130,14 +130,11 @@ fn conv_history_persist(hist: String) -> Void {
if str_eq(hist, "[]") { return "" } if str_eq(hist, "[]") { return "" }
let ts: Int = time_now() let ts: Int = time_now()
let tags: String = "[\"conv-history\",\"persistent\"]" let tags: String = "[\"conv-history\",\"persistent\"]"
let node_id: String = engram_node_full( let discard: String = engram_node_full(
hist, "Conversation", "conv:history", hist, "Conversation", "conv:history",
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9), el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags "Episodic", tags
) )
if str_eq(node_id, "") {
println("[chat] conv_history_persist: engram_node_full returned empty — history node lost")
}
} }
// conv_history_load restore conversation history from engram on first access. // conv_history_load restore conversation history from engram on first access.
@@ -634,6 +631,17 @@ fn handle_chat_agentic(body: String) -> String {
return "{\"error\":\"message required\",\"reply\":\"\"}" return "{\"error\":\"message required\",\"reply\":\"\"}"
} }
// Workspace scope (#23): the desktop UI sends the user-chosen Agent Workspace root
// on every agentic request. Persist it to state so agent_workspace_root() and the
// path/command tool guards that read it confine this turn's file/command tools to
// that subtree. The UI is the source of truth per request: empty means unscoped (the
// backward-compatible default), and it also lets agent_workspace_root() fall through
// to the NEURON_AGENT_ROOT env when no root is sent. FLAGGED FOR REVIEW: setting
// state from the body each turn (vs. only-when-nonempty) so clearing the folder in
// the UI un-scopes confirm this is the intended ownership model.
let ws_root: String = json_get(body, "agent_workspace_root")
state_set("agent_workspace_root", ws_root)
let req_model: String = json_get(body, "model") let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model } let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
@@ -649,7 +657,7 @@ fn handle_chat_agentic(body: String) -> String {
let session_valid: Bool = if str_eq(req_session, "") { let session_valid: Bool = if str_eq(req_session, "") {
true true
} else { } else {
!str_contains(session_get(req_session), "\"error\"") session_exists(req_session)
} }
if !session_valid { if !session_valid {
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}" return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
@@ -1072,19 +1080,13 @@ fn handle_dharma_room_turn(body: String) -> String {
// engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type // engram_node(content, "episodic", ...) which wrongly put a TIER into the node_type
// slot that's why nodes showed node_type="episodic". Use the full, correct contract.) // slot that's why nodes showed node_type="episodic". Use the full, correct contract.)
let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]" let utterance_tags: String = "[\"soul-utterance\",\"episodic\"]"
let utterance_id: String = engram_node_full( let discard_id: String = engram_node_full(
clean_response, "Conversation", "soul:utterance", clean_response, "Conversation", "soul:utterance",
el_from_float(0.6), el_from_float(0.6), el_from_float(0.8), el_from_float(0.6), el_from_float(0.6), el_from_float(0.8),
"Episodic", utterance_tags "Episodic", utterance_tags
) )
if str_eq(utterance_id, "") {
println("[chat] handle_dharma_room_turn: utterance engram write failed — node lost")
}
if !str_eq(snap_path, "") { if !str_eq(snap_path, "") {
let save_result: String = engram_save(snap_path) let discard_save: String = engram_save(snap_path)
if str_eq(save_result, "") {
println("[chat] handle_dharma_room_turn: engram_save failed for " + snap_path)
}
} }
let safe_response: String = json_safe(clean_response) let safe_response: String = json_safe(clean_response)
@@ -1166,7 +1168,7 @@ fn auto_persist(req: String, resp: String) -> Void {
+ ",\"label\":\"chat:" + ts_str + "\"}" + ",\"label\":\"chat:" + ts_str + "\"}"
let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]" let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]"
let persist_id: String = engram_node_full( engram_node_full(
content, content,
"Conversation", "Conversation",
"chat:" + ts_str, "chat:" + ts_str,
@@ -1176,9 +1178,6 @@ fn auto_persist(req: String, resp: String) -> Void {
"Episodic", "Episodic",
tags tags
) )
if str_eq(persist_id, "") {
println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")")
}
} }
// strengthen_chat_nodes strengthen the engram nodes that were activated during a chat. // strengthen_chat_nodes strengthen the engram nodes that were activated during a chat.
+2 -8
View File
@@ -46,10 +46,7 @@ fn mem_consolidate() -> String {
} }
fn mem_save(path: String) -> Void { fn mem_save(path: String) -> Void {
let save_result: String = engram_save(path) engram_save(path)
if str_eq(save_result, "") {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
}
} }
fn mem_load(path: String) -> Void { fn mem_load(path: String) -> Void {
@@ -79,14 +76,11 @@ fn mem_boot_count_inc() -> Int {
let next: Int = current + 1 let next: Int = current + 1
let content: String = "soul:boot_count:" + int_to_str(next) let content: String = "soul:boot_count:" + int_to_str(next)
let tags: String = "[\"soul-meta\",\"boot-counter\"]" let tags: String = "[\"soul-meta\",\"boot-counter\"]"
let boot_node_id: String = engram_node_full( let discard: String = engram_node_full(
content, "Memory", "soul:boot_count", content, "Memory", "soul:boot_count",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Canonical", tags "Canonical", tags
) )
if str_eq(boot_node_id, "") {
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
}
return next return next
} }
+2 -10
View File
@@ -400,7 +400,6 @@ fn handle_api_log_state_event(body: String) -> String {
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual", let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9), el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Episodic", tags) "Episodic", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}" return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
} }
@@ -453,7 +452,6 @@ fn handle_api_tune_config(body: String) -> String {
let id: String = engram_node_full(content, "ConfigEntry", key, let id: String = engram_node_full(content, "ConfigEntry", key,
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9), el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
"Canonical", tags) "Canonical", tags)
if !api_persisted(id) { return api_not_persisted(id) }
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}" return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
} }
@@ -653,23 +651,17 @@ fn handle_api_consolidate(body: String) -> String {
let summary: String = json_get(body, "summary") let summary: String = json_get(body, "summary")
let snap: String = state_get("soul_snapshot_path") let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") { if !str_eq(snap, "") {
let save_result: String = engram_save(snap) engram_save(snap)
if str_eq(save_result, "") {
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
}
} }
if !str_eq(summary, "") { if !str_eq(summary, "") {
let safe_summary: String = str_replace(summary, "\"", "'") let safe_summary: String = str_replace(summary, "\"", "'")
let tags: String = "[\"SessionSummary\",\"consolidate\"]" let tags: String = "[\"SessionSummary\",\"consolidate\"]"
let summary_id: String = engram_node_full( let discard: String = engram_node_full(
"[session-summary] " + safe_summary, "[session-summary] " + safe_summary,
"SessionSummary", "session:summary", "SessionSummary", "session:summary",
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
"Episodic", tags "Episodic", tags
) )
if str_eq(summary_id, "") {
println("[api] consolidate: session summary engram write failed — summary node lost")
}
} }
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}" return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
} }
+97 -1
View File
@@ -36,7 +36,49 @@ fn session_make_content(id: String, title: String, created_at: Int, updated_at:
+ ",\"updated_at\":" + int_to_str(updated_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}. // 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 { fn session_create(body: String) -> String {
let ts: Int = time_now() let ts: Int = time_now()
let id: String = uuid_v4() let id: String = uuid_v4()
@@ -55,8 +97,13 @@ fn session_create(body: String) -> String {
} }
// Store the engram node_id mapping so we can look up the node for this session // Store the engram node_id mapping so we can look up the node for this session
state_set("session_node_" + id, node_id) 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. // Maintain a state-based index for fast listing within this daemon run.
// Newest sessions first (prepend). // 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.
let existing_idx: String = state_get("session_index") 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 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, "") { let new_idx: String = if str_eq(existing_idx, "") {
@@ -73,6 +120,20 @@ fn session_create(body: String) -> String {
+ ",\"created_at\":" + int_to_str(ts) + "}" + ",\"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}]. // session_list list all sessions. Returns [{id, title, last_message, created_at, updated_at}].
fn session_list() -> String { fn session_list() -> String {
// Fast path: state-based index (rebuilt from session_create calls in this daemon run). // Fast path: state-based index (rebuilt from session_create calls in this daemon run).
@@ -222,13 +283,27 @@ fn session_delete(session_id: String) -> String {
state_set("session_hist_" + session_id, "") state_set("session_hist_" + session_id, "")
state_set("session_node_" + session_id, "") state_set("session_node_" + session_id, "")
state_set("session_index", "") 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 + "\"" return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
+ ",\"deleted_meta\":" + int_to_str(deleted_meta) + ",\"deleted_meta\":" + int_to_str(deleted_meta)
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}" + ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
} }
// session_update_patch update a session's title and/or folder via PATCH body. // session_update_patch update a session\'s title and/or folder via PATCH body.
// Body may contain "title", "folder", or both. Preserves unmentioned fields. // 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 { fn session_update_patch(session_id: String, body: String) -> String {
if str_eq(session_id, "") { if str_eq(session_id, "") {
return "{\"error\":\"session_id is required\"}" return "{\"error\":\"session_id is required\"}"
@@ -349,6 +424,9 @@ fn session_hist_load(session_id: String) -> String {
// session_hist_save persist message history for a session to state and engram. // session_hist_save persist message history for a session to state and engram.
fn session_hist_save(session_id: String, hist: String) -> Void { fn session_hist_save(session_id: String, hist: String) -> Void {
state_set("session_hist_" + session_id, hist) 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 // Delete old history node and write fresh one
let old_results: String = engram_search_json("session:messages:" + session_id, 3) 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 o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) }
@@ -371,6 +449,16 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
} }
// session_update_meta_timestamp update the updated_at field in the session:meta node. // 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 { fn session_update_meta_timestamp(session_id: String) -> Void {
let results: String = engram_search_json("session:meta " + session_id, 10) 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 total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
@@ -464,6 +552,14 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
// action: "allow" | "deny" | "always" // action: "allow" | "deny" | "always"
// Resumes the agentic loop from where it was paused. // 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 // Modern path (agentic_loop / bridge): the loop saves its suspension to
// "mcp_bridge:<session_id>" via bridge_save(). On approval we dispatch_tool() // "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() // if allowed (or build a denial string), then hand the result to agentic_resume()
+2 -10
View File
@@ -212,13 +212,8 @@ fn seed_persona_from_env() -> Void {
let h: Map = {} let h: Map = {}
map_set(h, "Content-Type", "application/json") map_set(h, "Content-Type", "application/json")
let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h) let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h)
// Check for empty response (timeout/network error), explicit error, or missing id. if str_contains(resp, "\"error\"") {
if str_eq(resp, "") {
println("[soul] persona HTTP write-back failed: empty response (timeout or network error) — in-memory only this session")
} else if str_contains(resp, "\"error\"") {
println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp) println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp)
} else if !str_contains(resp, "\"id\"") {
println("[soul] persona HTTP write-back: unexpected response (no id field) — in-memory only this session: " + resp)
} else { } else {
println("[soul] persona persisted to HTTP engram at " + engram_url) println("[soul] persona persisted to HTTP engram at " + engram_url)
} }
@@ -251,14 +246,11 @@ fn emit_session_start_event() -> Void {
+ ",\"ts\":" + int_to_str(ts) + "}" + ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]" let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
let session_event_id: String = engram_node_full( let discard: String = engram_node_full(
payload, "InternalStateEvent", "session-start", payload, "InternalStateEvent", "session-start",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0), el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Episodic", tags "Episodic", tags
) )
if str_eq(session_event_id, "") {
println("[soul] emit_session_start_event: engram write failed — session-start event lost")
}
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")") println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + ")")
} }