Files
neuron/memory.el
T
will.anderson 02bf2e7d81 Fix five latent bugs from temporal-precision code review
1. parse_salience_100: handle 3+ decimal digit salience strings correctly.
   The two-branch 'else { stripped }' case treated any N-digit decimal value
   as hundredths, so "0.125" (stripped=125) clamped to 100 instead of 12.
   Now divides by 10^(N-2) for N>2, mapping "0.125"->12, "0.375"->37, etc.

2. mem_consolidate Canonical scan: replaced single engram_scan_nodes_json(50,0)
   call with a paginated loop (page_size=50, advancing offset) so Canonical nodes
   beyond index 50 are no longer silently excluded from the periodic boost.

3. mem_consolidate Canonical strengthening: add salience ceiling guard so nodes
   already at the runtime maximum (serialised as "1" by %g) are skipped. Prevents
   monotonic unbounded salience growth across successive consolidation passes.

4. soul.el affective cutoff: replaced json_get(aff_node, "ts") with
   json_get(aff_node, "created_at") / "updated_at" fallback, consistent with
   handle_chat. The old "ts" field is not a standard engram node field; missing
   it caused the fallback to ts_now (always passes cutoff), over-including stale
   nodes. New behaviour defaults to 0 on missing timestamps (conservative exclude).

5. History byte-cap: implemented the existing TODO 32KB byte-cap. Added
   hist_trim_to_byte_cap() and applied it after count-based trim in both
   handle_chat and handle_chat_agentic. Prevents 100KB+ state entries at 40 turns
   during long technical sessions with large assistant responses.
2026-06-22 13:35:52 -05:00

193 lines
8.0 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 {
return engram_node_full(
content,
"Memory",
label,
el_from_float(0.5),
el_from_float(0.5),
el_from_float(0.8),
"Working",
tags
)
}
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)
}
fn mem_forget(node_id: String) -> Void {
engram_forget(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 full paginated scan so they resist
// temporal decay. Canonical nodes encode foundational identity they must not
// silently floor at 10. Page size 50, scanning until fewer than 50 nodes are
// returned (last page), so all Canonical nodes are reached even in large graphs.
// Without pagination, only the first 50 nodes in the graph were eligible; any
// Canonical node at index 50+ was silently excluded from the boost.
// Strengthening is skipped if the node's current salience is already at the
// runtime ceiling (represented as "1" by %g) to avoid monotonic unbounded growth.
// Canonical nodes with salience < 1.0 are strengthened each consolidation pass;
// once they reach the ceiling the runtime will no longer raise them further, so
// calling engram_strengthen at the ceiling is a no-op in the runtime anyway, but
// the explicit check makes the intent clear and avoids any runtime log noise.
let page_size: Int = 50
let scan_offset: Int = 0
let scan_done: Bool = false
while !scan_done {
let scan_result: String = engram_scan_nodes_json(page_size, scan_offset)
let scan_len: Int = json_array_len(scan_result)
if scan_len == 0 {
let scan_done = true
} else {
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")
let s_sal: String = json_get(s_node, "salience")
// Only strengthen if below the ceiling to prevent unbounded salience growth.
// engram serialises the ceiling as "1" (%g drops the decimal part when it
// is exactly zero). Any other value is below ceiling and should be boosted.
let at_ceiling: Bool = str_eq(s_sal, "1")
if str_eq(s_tier, "Canonical") && !str_eq(s_id, "") && !at_ceiling {
engram_strengthen(s_id)
let strengthened = strengthened + 1
}
let si = si + 1
}
let scan_offset = scan_offset + scan_len
// Fewer results than page_size means we've reached the last page.
if scan_len < page_size {
let scan_done = true
}
}
}
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 {
let save_result: String = 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 {
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 new node, return new count.
// Each boot creates a new "soul:boot_count:N" node. Old ones accumulate as
// history the search above always returns the highest value seen.
fn mem_boot_count_inc() -> Int {
let current: Int = mem_boot_count_get()
let next: Int = current + 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: engram write failed — boot counter node lost (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\"]"
return 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
)
}