Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02bf2e7d81 | |||
| 0ede112d05 |
@@ -12,39 +12,107 @@ fn chat_default_model() -> String {
|
||||
return "claude-sonnet-4-5"
|
||||
}
|
||||
|
||||
// parse_salience_100 — convert a salience/importance float string (as serialized by
|
||||
// %g format) to an integer in the range 0..100.
|
||||
//
|
||||
// The runtime serializes floats with %g which drops trailing zeros:
|
||||
// 1.0 -> "1" (no decimal at all)
|
||||
// 0.9 -> "0.9" (one decimal digit)
|
||||
// 0.85 -> "0.85" (two decimal digits)
|
||||
// 0.125 -> "0.125" (three decimal digits — %g does not round to 2 dp)
|
||||
//
|
||||
// The old approach of str_replace(s, ".", "") then str_to_int was broken:
|
||||
// "0.9" -> "09" -> str_to_int -> 9 (should be 90)
|
||||
// "0.5" -> "05" -> str_to_int -> 5 (should be 50)
|
||||
// "1" -> "1" -> str_to_int -> 1 (should be 100)
|
||||
// "0.85" -> "085" -> str_to_int -> 85 (accidentally correct)
|
||||
// "0.125" -> "0125" -> str_to_int -> 125 -> clamped to 100 (wrong: should be 12)
|
||||
//
|
||||
// Fix: detect presence and position of the decimal point, then scale accordingly.
|
||||
// - No decimal (e.g. "1"): multiply by 100.
|
||||
// - One decimal digit (e.g. "0.9"): multiply by 10 to get 90.
|
||||
// - Two decimal digits (e.g. "0.85"): use as-is (already hundredths).
|
||||
// - Three+ decimal digits: stripped integer is in units of 10^N (where N=digits
|
||||
// after the dot), so divide by 10^(N-2) to reduce to hundredths. Examples:
|
||||
// "0.125" -> stripped=125, N=3 -> 125/10 = 12
|
||||
// "0.375" -> stripped=375, N=3 -> 375/10 = 37
|
||||
// "0.625" -> stripped=625, N=3 -> 625/10 = 62
|
||||
// "0.875" -> stripped=875, N=3 -> 875/10 = 87
|
||||
fn parse_salience_100(s: String) -> Int {
|
||||
if str_eq(s, "") { return 70 }
|
||||
let dot_pos: Int = str_index_of(s, ".")
|
||||
let raw: Int = if dot_pos < 0 {
|
||||
let v: Int = str_to_int(s)
|
||||
v * 100
|
||||
} else {
|
||||
let after_dot: String = str_slice(s, dot_pos + 1, str_len(s))
|
||||
let decimal_digits: Int = str_len(after_dot)
|
||||
let stripped: Int = str_to_int(str_replace(s, ".", ""))
|
||||
if decimal_digits == 1 {
|
||||
stripped * 10
|
||||
} else {
|
||||
if decimal_digits == 2 {
|
||||
stripped
|
||||
} else {
|
||||
// 3+ decimal digits: divide out the extra precision to get hundredths.
|
||||
// extra = decimal_digits - 2; divisor = 10^extra.
|
||||
let extra: Int = decimal_digits - 2
|
||||
let divisor: Int = if extra == 1 { 10 } else {
|
||||
if extra == 2 { 100 } else {
|
||||
if extra == 3 { 1000 } else {
|
||||
if extra == 4 { 10000 } else { 100000 }
|
||||
}
|
||||
}
|
||||
}
|
||||
stripped / divisor
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw > 100 { 100 } else { if raw < 0 { 0 } else { raw } }
|
||||
}
|
||||
|
||||
// engram_score_node — compute a recency x relevance score for a single engram
|
||||
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
|
||||
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
|
||||
// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5.
|
||||
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
|
||||
// nodes to the bottom so they get trimmed when we cap context size.
|
||||
//
|
||||
// Recency uses a tier-aware decay curve instead of a flat linear slope:
|
||||
// - Canonical tiers decay very slowly: 365-day window (foundational identity).
|
||||
// - Episodic tiers decay at a moderate rate: 90-day window (conversation context).
|
||||
// - Working/untiered nodes decay at 30 days (transient task state).
|
||||
// - Floor is 10 (never zero) for all tiers.
|
||||
//
|
||||
// Uses max(created_at, updated_at) so recently-revised nodes are not penalised.
|
||||
fn engram_score_node(node_json: String) -> Int {
|
||||
let salience_str: String = json_get(node_json, "salience")
|
||||
let importance_str: String = json_get(node_json, "importance")
|
||||
let created_str: String = json_get(node_json, "created_at")
|
||||
let updated_str: String = json_get(node_json, "updated_at")
|
||||
let tier_str: String = json_get(node_json, "tier")
|
||||
|
||||
// Parse as floats via * 100 integer arithmetic (el has no float math)
|
||||
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
|
||||
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
|
||||
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
|
||||
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
||||
}
|
||||
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
|
||||
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
||||
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
||||
}
|
||||
let salience_100: Int = parse_salience_100(salience_str)
|
||||
let importance_100: Int = parse_salience_100(importance_str)
|
||||
|
||||
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
||||
let now_ts: Int = time_now()
|
||||
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
|
||||
let created_ts: Int = str_to_int(created_str)
|
||||
let age_secs: Int = now_ts - created_ts
|
||||
let age_days: Int = age_secs / 86400
|
||||
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||
let updated_ts: Int = if str_eq(updated_str, "") { 0 } else { str_to_int(updated_str) }
|
||||
let ref_ts: Int = if updated_ts > created_ts { updated_ts } else { created_ts }
|
||||
let age_secs: Int = now_ts - ref_ts
|
||||
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
|
||||
let is_canonical: Bool = str_eq(tier_str, "Canonical")
|
||||
let is_episodic: Bool = str_eq(tier_str, "Episodic")
|
||||
let decay: Int = if is_canonical {
|
||||
let drop: Int = if age_days >= 365 { 90 } else { age_days * 90 / 365 }
|
||||
100 - drop
|
||||
} else {
|
||||
if is_episodic {
|
||||
if age_days >= 90 { 10 } else { 100 - age_days }
|
||||
} else {
|
||||
if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||
}
|
||||
}
|
||||
if decay < 10 { 10 } else { decay }
|
||||
}
|
||||
|
||||
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
|
||||
return salience_100 * importance_100 * recency_100 / 10000
|
||||
}
|
||||
|
||||
@@ -151,16 +219,17 @@ fn engram_compile(intent: String) -> String {
|
||||
}
|
||||
|
||||
// Affective context: always include the most recent high-emotion memory if one
|
||||
// exists within 72 hours. This ensures continuity of care across turns — when
|
||||
// the user was in distress earlier in the session (or recently), that context
|
||||
// travels into every subsequent LLM call so the response register stays aware.
|
||||
// exists within 14 days. This ensures continuity of care across sessions — a
|
||||
// crisis on Friday must still carry into Monday (72h was too narrow for multi-day
|
||||
// distress arcs such as grief or recurring suicidal ideation). 14-day window
|
||||
// (1,209,600 seconds) covers sustained emotional arcs while excluding ancient
|
||||
// history. Unified with handle_chat and soul.el affective checks.
|
||||
// We search for BellEvent nodes specifically; these are written by auto_persist
|
||||
// when safety_detect_bell_level fires. The 72h window (259200 seconds) is wide
|
||||
// enough to span a multi-session day without pulling ancient history.
|
||||
// when safety_detect_bell_level fires.
|
||||
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
|
||||
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
|
||||
let now_ts: Int = time_now()
|
||||
let cutoff_ts: Int = now_ts - 259200
|
||||
let cutoff_ts: Int = now_ts - 1209600
|
||||
let recent_bell: String = if bell_ok {
|
||||
let bn0: String = json_array_get(bell_nodes, 0)
|
||||
// created_at is not present in engram node JSON for BellEvent nodes.
|
||||
@@ -354,6 +423,47 @@ fn hist_trim_with_bell_guard(hist: String) -> String {
|
||||
return hist
|
||||
}
|
||||
|
||||
// hist_trim_to_byte_cap — drop oldest user+assistant pairs until the history blob
|
||||
// is at or below `cap_bytes` in length, or until only 2 entries remain (the minimum
|
||||
// safe window). Uses the same structural json_array_len/json_array_get approach as
|
||||
// hist_trim to stay immune to content containing JSON marker strings.
|
||||
//
|
||||
// Called after count-based trimming to enforce a hard size ceiling on the history
|
||||
// blob. Without this cap, long technical sessions with large assistant responses
|
||||
// (code blocks, logs, analysis) can push the 40-turn window to 100KB+, which causes
|
||||
// engram_node_full writes to grow state entries unboundedly.
|
||||
fn hist_trim_to_byte_cap(hist: String, cap_bytes: Int) -> String {
|
||||
let current: String = hist
|
||||
let current_len: Int = str_len(current)
|
||||
while current_len > cap_bytes {
|
||||
let total: Int = json_array_len(current)
|
||||
// Never trim below 2 entries (1 pair).
|
||||
if total <= 2 {
|
||||
let current_len = 0 // exit loop
|
||||
} else {
|
||||
// Drop entries 0 and 1 (oldest pair).
|
||||
let result: String = ""
|
||||
let i: Int = 2
|
||||
while i < total {
|
||||
let entry: String = json_array_get(current, i)
|
||||
let result = if str_eq(result, "") {
|
||||
entry
|
||||
} else {
|
||||
result + "," + entry
|
||||
}
|
||||
let i = i + 1
|
||||
}
|
||||
if str_eq(result, "") {
|
||||
let current_len = 0 // exit loop
|
||||
} else {
|
||||
let current = "[" + result + "]"
|
||||
let current_len = str_len(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
// clean_llm_response — strips GPT-2 BPE byte-to-unicode artifacts that vLLM
|
||||
// emits when the tokenizer hasn't decoded back to raw bytes.
|
||||
//
|
||||
@@ -482,12 +592,14 @@ fn handle_chat(body: String) -> String {
|
||||
}
|
||||
|
||||
// Cross-session affective context: on session start (no history yet), check engram
|
||||
// for recent distress signals within 72h and prepend a care directive if found.
|
||||
// for recent distress signals within 14 days and prepend a care directive if found.
|
||||
// Extended from 72h: multi-day crisis must persist across Monday sessions starting
|
||||
// 3+ days after a Friday event. Consistent with engram_compile and soul.el checks.
|
||||
let affective_prefix: String = if hist_len == 0 {
|
||||
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
|
||||
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
|
||||
let now_ts: Int = time_now()
|
||||
let cutoff: Int = now_ts - 259200
|
||||
let cutoff: Int = now_ts - 1209600
|
||||
let found_recent: Bool = if has_nodes {
|
||||
let dn0: String = json_array_get(distress_nodes, 0)
|
||||
let ts0_raw: String = json_get(dn0, "created_at")
|
||||
@@ -606,16 +718,23 @@ fn handle_chat(body: String) -> String {
|
||||
let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response)
|
||||
// Use bell-guarded trim: if the evicted turn triggered a bell event, it is
|
||||
// preserved to engram before being dropped from the in-memory window.
|
||||
// Issue #8 (NO MAX SIZE GUARD): the 20-turn count limit bounds entry count, but individual
|
||||
// messages can be arbitrarily large (up to max_tokens = 4096 tokens each). At 20 turns the
|
||||
// history blob can reach ~80KB before trim fires. engram_node_full has no apparent size cap.
|
||||
// A byte-length cap would require truncating or summarising entries — too invasive here.
|
||||
// TODO: add a byte-length cap (e.g. 32KB) that drops oldest entries until under limit.
|
||||
let final_hist: String = if json_array_len(updated_hist2) > 20 {
|
||||
// Increased from 20 to 40 turns: long technical sessions lose early context at 20
|
||||
// (10 user + 10 assistant pairs). 40 turns preserves problem framing for multi-step
|
||||
// tasks while the bell guard still persists evicted distress turns to engram.
|
||||
// Byte-cap: after count-based trim, also trim oldest pairs until the history blob
|
||||
// is under 32KB. Long technical sessions with large assistant responses (code blocks,
|
||||
// analysis) can produce 100-160KB+ state entries at 40 turns; the count limit alone
|
||||
// is insufficient. We retain at least 2 entries (1 user + 1 assistant pair) regardless.
|
||||
let count_trimmed: String = if json_array_len(updated_hist2) > 40 {
|
||||
hist_trim_with_bell_guard(updated_hist2)
|
||||
} else {
|
||||
updated_hist2
|
||||
}
|
||||
let final_hist: String = if str_len(count_trimmed) > 32768 {
|
||||
hist_trim_to_byte_cap(count_trimmed, 32768)
|
||||
} else {
|
||||
count_trimmed
|
||||
}
|
||||
state_set("conv_history", final_hist)
|
||||
conv_history_persist(final_hist)
|
||||
|
||||
@@ -1193,7 +1312,14 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
let discard_hist: Bool = if !str_eq(reply_text, "") {
|
||||
let updated: String = hist_append(agentic_hist, "user", message)
|
||||
let updated2: String = hist_append(updated, "assistant", reply_text)
|
||||
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
|
||||
// Increased from 20 to 40 turns: consistent with handle_chat window expansion.
|
||||
// Byte-cap: also trim if the blob exceeds 32KB, consistent with handle_chat.
|
||||
let count_trimmed2: String = if json_array_len(updated2) > 40 { hist_trim(updated2) } else { updated2 }
|
||||
let trimmed: String = if str_len(count_trimmed2) > 32768 {
|
||||
hist_trim_to_byte_cap(count_trimmed2, 32768)
|
||||
} else {
|
||||
count_trimmed2
|
||||
}
|
||||
state_set(hist_key, trimmed)
|
||||
// Only persist the default global session to engram — named sessions are ephemeral.
|
||||
if str_eq(hist_key, "conv_history") {
|
||||
|
||||
@@ -35,14 +35,94 @@ 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 dummy: String = engram_scan_nodes_json(100, 0)
|
||||
let total_nodes: 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) + "}"
|
||||
+ ",\"total_edges\":" + int_to_str(total_edges)
|
||||
+ ",\"strengthened\":" + int_to_str(strengthened) + "}"
|
||||
}
|
||||
|
||||
fn mem_save(path: String) -> Void {
|
||||
|
||||
@@ -166,23 +166,40 @@ fn load_identity_context() -> Void {
|
||||
// Cross-session affective context: query engram for recent distress/crisis signals
|
||||
// at session start. Stored under soul_affective_context so the safety layer can
|
||||
// detect when a user has been in distress across previous sessions.
|
||||
// Soft recency guard: nodes with a ts field older than 7 days are skipped.
|
||||
// Results capped at 3 nodes, 200 chars each, to avoid over-injection into context.
|
||||
// Recency guard: nodes older than 14 days (1,209,600 seconds) are skipped.
|
||||
// Unified at 14 days with chat.el engram_compile and handle_chat affective checks
|
||||
// so all three paths present consistent affective context. The previous 7-day
|
||||
// (604800s) window was inconsistent with the 72h chat.el window, causing
|
||||
// conflicting context: soul.el loaded a 5-day-old crisis node while chat.el
|
||||
// did not include it on subsequent turns. Both now use 14 days.
|
||||
// Results capped at 3 nodes, 200 chars each, to limit context inflation.
|
||||
// TODO(recency): engram_search_json sorts by relevance, not timestamp. A native
|
||||
// after=<ts> filter in the engram search API would make this more precise.
|
||||
let affective_raw: String = engram_search_json("distress crisis upset hopeless", 3)
|
||||
let affective_raw: String = engram_search_json("distress crisis upset hopeless bell BellEvent", 3)
|
||||
let affective_ok: Bool = !str_eq(affective_raw, "") && !str_eq(affective_raw, "[]")
|
||||
if affective_ok {
|
||||
let ts_now: Int = time_now()
|
||||
let ts_cutoff: Int = ts_now - 604800
|
||||
let ts_cutoff: Int = ts_now - 1209600
|
||||
let aff_total: Int = json_array_len(affective_raw)
|
||||
let aff_ctx: String = ""
|
||||
let ai: Int = 0
|
||||
while ai < aff_total {
|
||||
let aff_node: String = json_array_get(affective_raw, ai)
|
||||
let aff_content: String = json_get(aff_node, "content")
|
||||
let aff_ts_str: String = json_get(aff_node, "ts")
|
||||
let aff_ts: Int = if str_eq(aff_ts_str, "") { ts_now } else { str_to_int(aff_ts_str) }
|
||||
// Use created_at (the standard engram node timestamp field), consistent
|
||||
// with handle_chat which reads created_at / updated_at. The previous
|
||||
// field name "ts" is not a standard engram field: it was present in some
|
||||
// BellEvent content payloads but absent from standard engram node JSON,
|
||||
// causing json_get to return "" and the fallback to ts_now — meaning ALL
|
||||
// nodes with a missing "ts" field appeared recent, over-including stale
|
||||
// content. With the 14-day window, this amplification was significant.
|
||||
// Fix: read created_at first, fall back to updated_at, then default to 0
|
||||
// (same as handle_chat). A ts of 0 always fails the cutoff check, so nodes
|
||||
// missing both timestamp fields are conservatively excluded rather than
|
||||
// blindly included.
|
||||
let aff_ca: String = json_get(aff_node, "created_at")
|
||||
let aff_ts_str: String = if str_eq(aff_ca, "") { json_get(aff_node, "updated_at") } else { aff_ca }
|
||||
let aff_ts: Int = if str_eq(aff_ts_str, "") { 0 } else { str_to_int(aff_ts_str) }
|
||||
let is_recent: Bool = aff_ts >= ts_cutoff
|
||||
let snip: String = if str_len(aff_content) > 200 { str_slice(aff_content, 0, 200) } else { aff_content }
|
||||
let aff_ctx = if is_recent && !str_eq(snip, "") {
|
||||
|
||||
Reference in New Issue
Block a user