Files
neuron/memory.el
will.anderson 091cc1fc0e Fix issue #150: fresh-install genesis boot SIGSEGV in mem_save
A fresh-install (SOUL_CGI_ID=ntn-genesis) boot crashed with
"Segmentation fault: 11" right after the http server came up — a real
customer's very first boot. Backtrace:

  strcmp(0x1) <- str_eq (el_runtime.c:219) <- mem_save <- awareness_run

Root cause: the el runtime's engram_save returns an Int (1 = ok, 0 =
failure), but mem_save did `str_eq(engram_save(path), "")`, treating the
return as a String. str_eq runs EL_CSTR on it, which is a raw cast:
EL_CSTR(1) = (char*)0x1. On a SUCCESSFUL save (return 1) strcmp then
dereferences 0x1 and segfaults. Genesis is the first path that both seeds
the brain AND saves it successfully on the very first awareness pass, so it
crashes there; non-genesis boots (contract gate, refusal test) don't hit a
successful early mem_save, which is why they passed. handle_api_consolidate
had the identical latent bug.

Fix: read engram_save's Int result and compare `== 0` instead of str_eq'ing
it — in mem_save (memory.el) and handle_api_consolidate (neuron-api.el).

Regression: pre-existing, NOT introduced by the immutability/floor rebuild.
The pre-immutability build (1442ce2) genesis-crashes identically in the same
unchanged mem_save; #159 never actually fixed #150 for a release-runtime
build.

Regenerated dist/soul.c + per-module dist/{memory,neuron-api}.c (3GB RSS
watchdog, built against release el_runtime v1.0.0-20260501). Verified:
genesis boot survives (/health 200, no segfault), verify-soul-contract.sh
GATE PASS (PRESENCE + IMMUTABILITY), and the bounded-persona floor is still
compiled in (BOUNDED PERSONA / SOUL_PERSONA_NAME strings present).
2026-07-21 11:50:59 -05:00

231 lines
9.7 KiB
EmacsLisp

fn tier_working() -> String { return "Working" }
fn tier_episodic() -> String { return "Episodic" }
fn tier_canonical() -> String { return "Canonical" }
fn mem_store(content: String, label: String, tags: String) -> String {
let id: String = engram_node_full(
content,
"Memory",
label,
el_from_float(0.5),
el_from_float(0.5),
el_from_float(0.8),
"Working",
tags
)
if str_eq(id, "") {
println("[memory] write rejected by engram (empty id): label=" + label)
return ""
}
// Read back to verify the node actually persisted guards against silent write failures.
let readback: String = engram_get_node_json(id)
if str_eq(readback, "") || str_eq(readback, "{}") {
println("[memory] WRITE VERIFY FAILED: label=" + label + " id=" + id + " — node absent after write")
return ""
}
println("[memory] write verified: " + id + " ok")
return id
}
fn mem_remember(content: String, tags: String) -> String {
return mem_store(content, "soul-memory", tags)
}
fn mem_recall(query: String, depth: Int) -> String {
return engram_activate_json(query, depth)
}
fn mem_search(query: String, limit: Int) -> String {
return engram_search_json(query, limit)
}
fn mem_strengthen(node_id: String) -> Void {
engram_strengthen(node_id)
}
// mem_tombstone immutable "delete": KEEP the node and all its edges; record a
// Tombstone marker (content = target id, label "tombstone:<id>", wired with a
// "tombstones" edge). Never engram_forget. Default bounded list reads hide
// tombstoned nodes; ?include_deleted=1 recovers them. This is the ONE canonical
// tombstone helper every forget path routes through it. Defined here in
// memory.el (imported first) so awareness.el and neuron-api.el can both call it.
fn mem_tombstone(node_id: String) -> String {
let tags: String = "[\"Tombstone\",\"status:deleted\"]"
let marker: String = engram_node_full(
node_id, "Tombstone", "tombstone:" + node_id,
el_from_float(0.01), el_from_float(0.01), el_from_float(1.0),
"Episodic", tags)
if !str_eq(marker, "") {
engram_connect(marker, node_id, el_from_float(1.0), "tombstones")
}
return marker
}
// mem_forget NOTE: no longer a hard delete. Engram nodes are immutable, so
// this now TOMBSTONES (via mem_tombstone): the node and its edges are kept and
// stay recoverable. Every caller (the /memory/forget route and the cultivate
// forget op) is non-destructive as a result. Internal GC that genuinely needs
// removal (session-summary replace, telemetry pruning) calls engram_forget
// directly and is unaffected by this.
fn mem_forget(node_id: String) -> Void {
let _marker: String = mem_tombstone(node_id)
}
// mem_consolidate structural scan plus salience-evolution pass.
//
// Previously this only returned structural counts (scanned, total_nodes, total_edges)
// with no salience updates. No node salience ever changed based on recall frequency
// or time; foundational nodes decayed identically to ephemeral chat; frequently-recalled
// nodes were never promoted. This made consolidation a no-op.
//
// New behavior:
// (a) Strengthen frequently-activated nodes: nodes in the top working-memory list
// (engram_wm_top_json) are strengthened they have been recalled recently
// and deserve higher salience. Raises effective salience for nodes that prove
// relevant across multiple sessions.
// (b) Strengthen Canonical-tier nodes: identity and foundational nodes should not
// decay; each consolidation pass re-strengthens them so they resist the
// tier-aware decay curve without requiring active recall.
// (c) Structural counts are still returned for observability.
//
// Called by awareness_run() on the "consolidate" inbox action.
fn mem_consolidate() -> String {
let scanned: Int = engram_node_count()
let total_edges: Int = engram_edge_count()
let strengthened: Int = 0
// (a) Strengthen top working-memory nodes recalled recently across sessions.
// Cap at 10 to keep consolidation fast.
let wm_top: String = engram_wm_top_json(10)
let wm_len: Int = json_array_len(wm_top)
let wi: Int = 0
while wi < wm_len {
let wm_node: String = json_array_get(wm_top, wi)
let wm_id: String = json_get(wm_node, "id")
if !str_eq(wm_id, "") {
engram_strengthen(wm_id)
let strengthened = strengthened + 1
}
let wi = wi + 1
}
// (b) Strengthen Canonical-tier nodes from a scan so they resist temporal decay.
// Canonical nodes encode foundational identity they must not silently floor at 10.
let scan_result: String = engram_scan_nodes_json(50, 0)
let scan_len: Int = json_array_len(scan_result)
let si: Int = 0
while si < scan_len {
let s_node: String = json_array_get(scan_result, si)
let s_tier: String = json_get(s_node, "tier")
let s_id: String = json_get(s_node, "id")
if str_eq(s_tier, "Canonical") && !str_eq(s_id, "") {
engram_strengthen(s_id)
let strengthened = strengthened + 1
}
let si = si + 1
}
let total_nodes: Int = engram_node_count()
return "{\"scanned\":" + int_to_str(scanned)
+ ",\"total_nodes\":" + int_to_str(total_nodes)
+ ",\"total_edges\":" + int_to_str(total_edges)
+ ",\"strengthened\":" + int_to_str(strengthened) + "}"
}
fn mem_save(path: String) -> Void {
// engram_save returns an Int (1 = ok, 0 = failure), NOT a String. Calling
// str_eq on it casts EL_CSTR(1) -> (char*)0x1 and SIGSEGVs on a SUCCESSFUL
// save which is exactly what a fresh-install genesis boot does first
// (seeds the brain, saves, crashes). This is issue #150. Check the Int.
let saved: Int = engram_save(path)
if saved == 0 {
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
}
}
fn mem_load(path: String) -> Void {
engram_load(path)
}
// mem_boot_count_get retrieve current boot count from engram.
// Searches for the "soul:boot_count" node and returns its numeric value.
// Returns 0 if not found.
fn mem_boot_count_get() -> Int {
let results: String = engram_search_json("soul:boot_count", 3)
if str_eq(results, "") { return 0 }
if str_eq(results, "[]") { return 0 }
let node: String = json_array_get(results, 0)
let content: String = json_get(node, "content")
let prefix: String = "soul:boot_count:"
if !str_starts_with(content, prefix) { return 0 }
let num_str: String = str_slice(content, str_len(prefix), str_len(content))
return str_to_int(num_str)
}
// mem_boot_count_inc increment boot counter, store a single canonical node, return new count.
// Prunes ALL existing soul:boot_count nodes before inserting the new one so there is
// always at most ONE such node in the graph. Without pruning, engram_node_full inserts
// a new node every boot (no upsert) and the old ones accumulate. The search-first
// approach also fixes a latent ordering bug: engram_search_json returns oldest-first,
// so mem_boot_count_get() with limit=3 would read a stale (lower) count once more
// than 3 copies accumulate.
fn mem_boot_count_inc() -> Int {
let current: Int = mem_boot_count_get()
let next: Int = current + 1
// Prune all existing boot_count nodes keep exactly one.
let old_results: String = engram_search_json("soul:boot_count", 50)
if !str_eq(old_results, "") && !str_eq(old_results, "[]") {
let old_len: Int = json_array_len(old_results)
let oi: Int = 0
while oi < old_len {
let old_node: String = json_array_get(old_results, oi)
let old_id: String = json_get(old_node, "id")
if !str_eq(old_id, "") {
engram_forget(old_id)
}
let oi = oi + 1
}
}
let content: String = "soul:boot_count:" + int_to_str(next)
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
let boot_node_id: String = engram_node_full(
content, "Memory", "soul:boot_count",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Canonical", tags
)
if str_eq(boot_node_id, "") {
println("[memory] mem_boot_count_inc: write rejected (empty id) — boot counter node lost (count=" + int_to_str(next) + ")")
return next
}
let boot_readback: String = engram_get_node_json(boot_node_id)
if str_eq(boot_readback, "") || str_eq(boot_readback, "{}") {
println("[memory] mem_boot_count_inc: WRITE VERIFY FAILED id=" + boot_node_id + " count=" + int_to_str(next))
}
return next
}
// mem_emit_state_event log an internal state event as structured memory.
// Schema: {trigger, kind, content, boot, ts}
// This creates an auditable evidence trail of cognitive decisions.
fn mem_emit_state_event(trigger: String, kind: String, content: String) -> String {
let boot: Int = mem_boot_count_get()
let ts: Int = time_now()
let safe_trigger: String = str_replace(trigger, "\"", "'")
let safe_content: String = str_replace(content, "\"", "'")
let payload: String = "{\"trigger\":\"" + safe_trigger + "\""
+ ",\"kind\":\"" + kind + "\""
+ ",\"content\":\"" + safe_content + "\""
+ ",\"boot\":" + int_to_str(boot)
+ ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"pre-reasoning\",\"InternalStateEvent\"]"
let event_id: String = engram_node_full(
payload, "InternalStateEvent", "state-event:" + kind,
el_from_float(0.85), el_from_float(0.8), el_from_float(0.9),
"Episodic", tags
)
if str_eq(event_id, "") {
println("[memory] mem_emit_state_event: write rejected (empty id): kind=" + kind)
}
return event_id
}