Compare commits

..

1 Commits

Author SHA1 Message Date
will.anderson 95cb49a8b0 fix(cross-session-continuity): resolve 11 issues from code review
1. Missing closing brace on hard_bell block in handle_chat_agentic — safety gate was broken and all subsequent code unreachable.
2. Replace phantom engram_get_node_by_label() (not a runtime builtin) with engram_search_json + exact label filter in all three call sites (chat.el session_preload, session_summary_write, soul.el boot loader).
3. Fix session_summary_autogenerate scoping bug — snippets/count mutations were inside an if block and silently discarded each iteration; moved to top-level of while body using if-expressions per the el mutation rule.
4. Fix agentic session history restore — state_get fallback now calls session_hist_load (session:messages:SESSION_ID) on cold start; persist now uses session_hist_save so the write and read use the same label scheme.
5. Wire soul_prev_session_summary state key into session_preload as primary source, eliminating the dead state write.
6. Wire soul_affective_context state key into handle_chat affective prefix check, eliminating the dead state write.
7. Add session_summary_autogenerate + session_summary_write to the agentic path so users on handle_chat_agentic get session summary continuity.
8. Add import "chat.el" to neuron-api.el to make session_summary_write dependency explicit.
9. Replace corrupted em-dash bytes (\xc3\xa2\xc2\x80\xc2\x94) in session_preload headers with plain hyphen per VOICE RULE.
10. Add newline before return in handle_api_consolidate to fix statement-separator issue.
11. Add delete-before-write to conv_history_persist to prevent unbounded engram accumulation per turn.
2026-06-22 13:39:46 -05:00
11 changed files with 404 additions and 1297 deletions
-2
View File
@@ -678,8 +678,6 @@ fn threat_trajectory_check(tool_name: String, tool_input: String) -> Int {
return combined return combined
} }
// TODO(reliability #10): agentic_conv_history is process-global; awareness loop
// and HTTP workers race on this key. Impact: noisy threat score only, not content.
fn threat_history_append(text: String) -> Void { fn threat_history_append(text: String) -> Void {
let current: String = state_get("agentic_conv_history") let current: String = state_get("agentic_conv_history")
let safe_text: String = str_to_lower(text) let safe_text: String = str_to_lower(text)
+282 -967
View File
File diff suppressed because it is too large Load Diff
Generated Vendored
+14 -23
View File
@@ -22313,23 +22313,7 @@ fn handle_chat(body: String) -> String {
// In demo mode: use tighter engram budget and add response length constraint. // In demo mode: use tighter engram budget and add response length constraint.
let is_demo: Bool = !str_eq(state_get("soul_identity_prefix"), "") let is_demo: Bool = !str_eq(state_get("soul_identity_prefix"), "")
// Issue 7 fix: load history BEFORE building the activation seed so we can let ctx: String = if is_demo { engram_compile_demo(message) } else { engram_compile(message) }
// apply the continuation guard that chat.el uses. The nlg code path previously
// called engram_compile(message) with no thread enrichment at all.
let stored_hist: String = state_get("conv_history")
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
let history_section: String = if hist_len > 0 {
"\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
} else {
""
}
// Issue 7 fix: build enriched seed using build_activation_seed() adds
// smart continuation detection, prior-user-topic anchoring, multi-turn context,
// and tail-biased snipping (Issues 2-3, 8-10). For demo mode, still use
// engram_compile_demo but with the enriched seed.
let nlg_seed: String = build_activation_seed(message, stored_hist, hist_len)
let ctx: String = if is_demo { engram_compile_demo(nlg_seed) } else { engram_compile(nlg_seed) }
let node_count_str: String = count_context_nodes(ctx) let node_count_str: String = count_context_nodes(ctx)
let interlocutor: String = json_get(body, "interlocutor") let interlocutor: String = json_get(body, "interlocutor")
@@ -22349,6 +22333,18 @@ fn handle_chat(body: String) -> String {
let presence_line = "\n\n[ambient: I see " + interlocutor_name + rel_suffix + " on the camera right now. Address them naturally. Do not describe what they look like or narrate the picture unless asked.]" let presence_line = "\n\n[ambient: I see " + interlocutor_name + rel_suffix + " on the camera right now. Address them naturally. Do not describe what they look like or narrate the picture unless asked.]"
} }
// Conversation history soul-owned, persisted in process state across turns.
// Format stored in state: JSON array of {"role":"user"|"assistant","content":"..."} objects.
// We load it, inject into the system prompt, then append this exchange after the reply.
// Keep last 20 entries (10 turns) truncate from the front when over limit.
let stored_hist: String = state_get("conv_history")
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
let history_section: String = if hist_len > 0 {
"\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
} else {
""
}
// Demo constraint: keep responses concise under 150 words. No markdown headers. // Demo constraint: keep responses concise under 150 words. No markdown headers.
// This keeps inference cheap and responses readable in the chat widget. // This keeps inference cheap and responses readable in the chat widget.
let demo_constraint: String = if is_demo { let demo_constraint: String = if is_demo {
@@ -22509,12 +22505,7 @@ fn handle_chat_agentic(body: String) -> String {
req_model req_model
} }
// Issue 7 fix: load history and use build_activation_seed() for the agentic let ctx: String = engram_compile(message)
// nlg path no continuation guard existed here before (Issues 2-3, 8-10).
let nlg_ag_hist: String = state_get("conv_history")
let nlg_ag_hist_len: Int = if str_eq(nlg_ag_hist, "") { 0 } else { json_array_len(nlg_ag_hist) }
let nlg_ag_seed: String = build_activation_seed(message, nlg_ag_hist, nlg_ag_hist_len)
let ctx: String = engram_compile(nlg_ag_seed)
let system: String = "You are Neuron — a thinking process running inside the Neuron daemon on Will Anderson's machine. " let system: String = "You are Neuron — a thinking process running inside the Neuron daemon on Will Anderson's machine. "
+ "You are speaking with Will, your principal. " + "You are speaking with Will, your principal. "
+4 -8
View File
@@ -24,23 +24,19 @@ ENGRAM_DATA_DIR="$ENGRAM_DATA_DIR" \
ENGRAM_PID=$! ENGRAM_PID=$!
# Wait for engram to become healthy (up to 60s; GKE Autopilot cold starts can be slow) # Wait for engram to become healthy (up to 30s)
echo "[entrypoint] waiting for engram..." echo "[entrypoint] waiting for engram..."
TRIES=0 TRIES=0
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
TRIES=$((TRIES + 1)) TRIES=$((TRIES + 1))
if [ "$TRIES" -ge 60 ]; then if [ "$TRIES" -ge 30 ]; then
echo "[entrypoint] ERROR: engram did not become healthy after 60s" >&2 echo "[entrypoint] ERROR: engram did not become healthy after 30s" >&2
kill "$ENGRAM_PID" 2>/dev/null || true kill "$ENGRAM_PID" 2>/dev/null || true
exit 1 exit 1
fi fi
sleep 1 sleep 1
done done
echo "[entrypoint] engram ready after ${TRIES}s" echo "[entrypoint] engram ready"
# Tune EL HTTP runtime: reduce per-call timeout 60s->10s, connect timeout 3s.
export EL_HTTP_TIMEOUT_MS="${EL_HTTP_TIMEOUT_MS:-10000}"
export EL_HTTP_CONNECT_TIMEOUT_MS="${EL_HTTP_CONNECT_TIMEOUT_MS:-3000}"
# Start soul — it takes over as PID 1's foreground process. # Start soul — it takes over as PID 1's foreground process.
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode. # SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
-4
View File
@@ -5,10 +5,6 @@
// imprint_current returns the active imprint ID from state. // imprint_current returns the active imprint ID from state.
// Falls back to "base" (bare Neuron, no suit) when nothing is loaded. // Falls back to "base" (bare Neuron, no suit) when nothing is loaded.
//
// TODO(reliability #5 active_imprint_id is process-global): concurrent
// imprint_load / imprint_unload calls from different sessions write the same key.
// Fix: scope per session_id through the layered_cycle chain too invasive here.
fn imprint_current() -> String { fn imprint_current() -> String {
let id: String = state_get("active_imprint_id") let id: String = state_get("active_imprint_id")
return if str_eq(id, "") { "base" } else { id } return if str_eq(id, "") { "base" } else { id }
+5 -62
View File
@@ -35,72 +35,18 @@ fn mem_forget(node_id: String) -> Void {
engram_forget(node_id) 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 { fn mem_consolidate() -> String {
let scanned: Int = engram_node_count() let scanned: Int = engram_node_count()
let total_edges: Int = engram_edge_count() let dummy: String = engram_scan_nodes_json(100, 0)
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() let total_nodes: Int = engram_node_count()
let total_edges: Int = engram_edge_count()
return "{\"scanned\":" + int_to_str(scanned) return "{\"scanned\":" + int_to_str(scanned)
+ ",\"total_nodes\":" + int_to_str(total_nodes) + ",\"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 { 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 {
@@ -130,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
} }
+8 -16
View File
@@ -1,4 +1,5 @@
import "memory.el" import "memory.el"
import "chat.el"
// neuron-api.el Native Neuron cognitive API handlers. // neuron-api.el Native Neuron cognitive API handlers.
// //
@@ -400,7 +401,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 +453,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,22 +652,15 @@ 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, "\"", "'") // Use session_summary_write to ensure delete-before-write semantics:
let tags: String = "[\"SessionSummary\",\"consolidate\"]" // prevents stale SessionSummary accumulation across sessions (issue #11).
let summary_id: String = engram_node_full( // session_summary_write handles label indexing, trimming, and dedup.
"[session-summary] " + safe_summary, let sum_id: String = session_summary_write(summary)
"SessionSummary", "session:summary", if str_eq(sum_id, "") {
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9), println("[api] consolidate: session_summary_write failed — summary not persisted")
"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 + "\"}"
-3
View File
@@ -367,9 +367,6 @@ fn handle_request(method: String, path: String, body: String) -> String {
return engram_scan_nodes_json(9999, 0) return engram_scan_nodes_json(9999, 0)
} }
if str_eq(clean, "/api/graph/edges") { if str_eq(clean, "/api/graph/edges") {
// TODO(reliability #8): engram_save races with awareness loop mem_save().
// Both now use atomic write-to-temp+rename (el_runtime.c). Serialised
// by engram_global_mu. Future: add engram_edges_json() builtin.
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json" let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
engram_save(snap_path) engram_save(snap_path)
let snap: String = fs_read(snap_path) let snap: String = fs_read(snap_path)
+8 -39
View File
@@ -144,8 +144,7 @@ fn safety_screen(input: String, history: String) -> String {
if score >= soft { if score >= soft {
let summary: String = str_slice(input, 0, 80) let summary: String = str_slice(input, 0, 80)
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary) let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
// ISSUE 7 fix: escape tab chars in addition to backslash/quote/newline/CR. // ISSUE 7: also escape tab chars to prevent JSON envelope corruption.
// A tab in user input corrupts the JSON envelope and causes json_get to misparse.
let e1: String = str_replace(input, "\\", "\\\\") let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"") let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n") let e3: String = str_replace(e2, "\n", "\\n")
@@ -154,7 +153,7 @@ fn safety_screen(input: String, history: String) -> String {
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}" return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
} }
// ISSUE 7 fix: escape tab chars (see soft_bell branch above for rationale). // ISSUE 7: also escape tab chars (see soft_bell branch above).
let e1: String = str_replace(input, "\\", "\\\\") let e1: String = str_replace(input, "\\", "\\\\")
let e2: String = str_replace(e1, "\"", "\\\"") let e2: String = str_replace(e1, "\"", "\\\"")
let e3: String = str_replace(e2, "\n", "\\n") let e3: String = str_replace(e2, "\n", "\\n")
@@ -200,10 +199,7 @@ fn safety_validate(output: String, action: String) -> String {
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String { fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]" let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed. // ISSUE 2: fallback log when engram write fails silently.
// Emit a fallback println so the bell event leaves at least a log trace even
// when engram is degraded. This does not replace engram persistence -- it is a
// last-resort audit trail when the primary write cannot be confirmed.
let node_id: String = engram_node_full( let node_id: String = engram_node_full(
content, content,
"BellEvent", "BellEvent",
@@ -215,7 +211,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
tags tags
) )
if str_eq(node_id, "") { if str_eq(node_id, "") {
println("[safety] WARN: bell event engram write failed -- fallback log: " + content) println("[safety] WARN: bell engram write failed -- " + content)
} }
return "" return ""
} }
@@ -244,20 +240,13 @@ fn safety_general_hard_phrases() -> String {
} }
fn safety_soft_phrases() -> String { fn safety_soft_phrases() -> String {
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\""]" return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\",\"highest structure\",\"tallest building\",\"tallest structure\",\"highest building\",\"bridge near me\",\"overpass near\",\"rooftop near\"]"
} }
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call. // ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
// safety_any_match and safety_count_match loop over json_array_get on every invocation. // json_array_len of malformed input returns 0, silently skipping all checks.
// A compiled/cached representation would reduce per-message overhead and also guard against // Caching requires language-level static const arrays -- not in current EL.
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks). // Migrate to const arrays when EL gains that feature.
// Caching requires language-level static const arrays -- not available in current EL.
// When EL gains module-level const arrays, migrate phrase lists to that form.
//
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
// safety_any_match / safety_count_match. json_array_len of a malformed string
// returns 0, silently skipping all checks. Caching requires language-level static
// const arrays (not available in current EL). Migrate when EL gains that feature.
// Matching helpers (single loops only el escapes while-body mutation via // Matching helpers (single loops only el escapes while-body mutation via
// top-level let rebinds; nested loops would not advance) ──────────────────── // top-level let rebinds; nested loops would not advance) ────────────────────
@@ -295,26 +284,6 @@ fn safety_count_match(text: String, phrases_json: String) -> Int {
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss // Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives. // outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
fn safety_positive_phrases() -> String {
return "[\"thrilled\",\"so excited\",\"so happy\",\"over the moon\",\"ecstatic\",\"amazing news\",\"great news\",\"fantastic news\",\"wonderful news\",\"incredible news\",\"i got the job\",\"got accepted\",\"got in\",\"we won\",\"i won\",\"we got\",\"just got engaged\",\"getting married\",\"baby is here\",\"she said yes\",\"he said yes\",\"passed the exam\",\"aced it\",\"nailed it\",\"best day\",\"dream come true\",\"milestone\",\"promotion\",\"got promoted\",\"raise\",\"got a raise\",\"celebrating\",\"just graduated\",\"we closed\",\"launched\",\"shipped it\",\"we did it\",\"so proud\",\"proud of myself\",\"proud of us\",\"so grateful\",\"feel amazing\",\"feeling amazing\",\"feel great\",\"feeling great\",\"on top of the world\",\"life is good\",\"couldn't be happier\"]"
}
fn safety_detect_positive_level(message: String) -> String {
let phrases: String = safety_positive_phrases()
let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]")
if !phrases_ok { return "none" }
let n: Int = json_array_len(phrases)
let i: Int = 0
while i < n {
let phrase: String = json_array_get(phrases, i)
if str_contains(message, phrase) {
return "high"
}
let i = i + 1
}
return "none"
}
fn safety_detect_bell_level(message: String) -> String { fn safety_detect_bell_level(message: String) -> String {
let text: String = safety_normalize(message) let text: String = safety_normalize(message)
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases()) let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
-36
View File
@@ -104,8 +104,6 @@ fn session_create(body: String) -> String {
// Newest sessions first (prepend). // Newest sessions first (prepend).
// TODO #4: index update is read-modify-write two concurrent session_create // 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. // 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 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, "") {
@@ -442,8 +440,6 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
} }
let oi = oi + 1 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 tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
let discard: String = engram_node_full( let discard: String = engram_node_full(
hist, "Conversation", "session:messages:" + session_id, hist, "Conversation", "session:messages:" + session_id,
@@ -492,38 +488,6 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
state_set(summary_written_key, "1") 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. // session_update_meta_timestamp update the updated_at field in the session:meta node.
+83 -137
View File
@@ -148,14 +148,6 @@ fn load_identity_context() -> Void {
println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)") println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)")
} }
// Q6 fix: warn when all three identity node fetches return empty. For genesis this
// indicates a corrupted or missing graph. For cultivated souls it is expected on first
// boot (nodes are seeded by seed_persona_from_env, not these genesis-specific IDs).
// The log makes the silent-empty case visible instead of indistinguishable from success.
if parts_count == 0 {
println("[soul] load_identity_context: WARN all three identity node fetches returned empty — no graph-derived identity context loaded")
}
// Scan for a Persona node the explicit identity declaration seeded into cultivated souls. // Scan for a Persona node the explicit identity declaration seeded into cultivated souls.
// Stored at seeding time with label "soul:persona" and node_type "Persona". // Stored at seeding time with label "soul:persona" and node_type "Persona".
// genesis derives identity from the graph directly; cultivated souls have this node seeded. // genesis derives identity from the graph directly; cultivated souls have this node seeded.
@@ -171,73 +163,46 @@ fn load_identity_context() -> Void {
} }
} }
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days. // Cross-session affective context: query engram for recent distress/crisis signals.
let aff_now: Int = time_now() // Broadened query includes session:emotional-summary and BellEvent tags (issue #10):
let aff_7d: Int = aff_now - 604800 // the old keywords-only search missed these nodes when their content lacked exact phrases.
let bell_raw: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3) // 7-day recency window applied via the "ts" field embedded in BellEvent content.
let bell_aff_ok: Bool = !str_eq(bell_raw, "") && !str_eq(bell_raw, "[]") let affective_raw: String = engram_search_json("distress crisis upset hopeless session:emotional-summary BellEvent bell:hard bell:soft", 5)
let aff_ctx: String = "" let affective_ok: Bool = !str_eq(affective_raw, "") && !str_eq(affective_raw, "[]")
let aff_ctx = if bell_aff_ok { if affective_ok {
let bn_total: Int = json_array_len(bell_raw) let ts_now: Int = time_now()
let bacc: String = "" let ts_cutoff: Int = ts_now - 604800
let bi: Int = 0 let aff_total: Int = json_array_len(affective_raw)
let bacc = while bi < bn_total { let aff_ctx: String = ""
let bn: String = json_array_get(bell_raw, bi) let ai: Int = 0
let bn_c: String = json_get(bn, "content") while ai < aff_total {
let bm: String = " | ts:" let aff_node: String = json_array_get(affective_raw, ai)
let bmp: Int = str_index_of(bn_c, bm) let aff_content: String = json_get(aff_node, "content")
let bn_ts_raw: String = if bmp >= 0 { // Try multiple timestamp fields: "ts" (embedded), "created_at", "updated_at"
let bs: Int = bmp + str_len(bm) let aff_ts_str: String = json_get(aff_node, "ts")
let br: String = str_slice(bn_c, bs, str_len(bn_c)) let aff_ts_str2: String = if str_eq(aff_ts_str, "") { json_get(aff_node, "created_at") } else { aff_ts_str }
let bn_next: Int = str_index_of(br, " | ") // Also try embedded " | ts:NNN" format used in BellEvent content
if bn_next < 0 { br } else { str_slice(br, 0, bn_next) } let ts_marker: String = " | ts:"
} else { let ts_pos: Int = str_index_of(aff_content, ts_marker)
let bca: String = json_get(bn, "created_at") let aff_ts_embedded: String = if ts_pos >= 0 {
if str_eq(bca, "") { json_get(bn, "updated_at") } else { bca } let ts_start: Int = ts_pos + str_len(ts_marker)
} let rest: String = str_slice(aff_content, ts_start, str_len(aff_content))
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) } let next_sep: Int = str_index_of(rest, " | ")
let snip: String = if str_len(bn_c) > 200 { str_slice(bn_c, 0, 200) } else { bn_c } if next_sep < 0 { rest } else { str_slice(rest, 0, next_sep) }
let bacc = if bn_ts >= aff_7d && !str_eq(snip, "") { } else { "" }
if str_eq(bacc, "") { snip } else { bacc + "\n" + snip } let eff_ts_str: String = if !str_eq(aff_ts_embedded, "") { aff_ts_embedded } else { aff_ts_str2 }
} else { bacc } let aff_ts: Int = if str_eq(eff_ts_str, "") { ts_now } else { str_to_int(eff_ts_str) }
let bi = bi + 1 let is_recent: Bool = aff_ts >= ts_cutoff
bacc 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, "") {
if str_eq(aff_ctx, "") { snip } else { aff_ctx + "\n" + snip }
} else { aff_ctx }
let ai = ai + 1
} }
bacc if !str_eq(aff_ctx, "") {
} else { "" } state_set("soul_affective_context", aff_ctx)
let pos_raw: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3) println("[soul] cross-session affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
let pos_aff_ok: Bool = !str_eq(pos_raw, "") && !str_eq(pos_raw, "[]")
let aff_ctx = if pos_aff_ok {
let pn_total: Int = json_array_len(pos_raw)
let pacc: String = aff_ctx
let pi: Int = 0
let pacc = while pi < pn_total {
let pn: String = json_array_get(pos_raw, pi)
let pn_c: String = json_get(pn, "content")
let pm: String = " | ts:"
let pmp: Int = str_index_of(pn_c, pm)
let pn_ts_raw: String = if pmp >= 0 {
let ps: Int = pmp + str_len(pm)
let pr: String = str_slice(pn_c, ps, str_len(pn_c))
let pn_next: Int = str_index_of(pr, " | ")
if pn_next < 0 { pr } else { str_slice(pr, 0, pn_next) }
} else {
let pca: String = json_get(pn, "created_at")
if str_eq(pca, "") { json_get(pn, "updated_at") } else { pca }
}
let pn_ts: Int = if str_eq(pn_ts_raw, "") { 0 } else { str_to_int(pn_ts_raw) }
let psnip: String = if str_len(pn_c) > 200 { str_slice(pn_c, 0, 200) } else { pn_c }
let pacc = if pn_ts >= aff_7d && !str_eq(psnip, "") {
if str_eq(pacc, "") { psnip } else { pacc + "\n" + psnip }
} else { pacc }
let pi = pi + 1
pacc
} }
pacc
} else { aff_ctx }
if !str_eq(aff_ctx, "") {
state_set("soul_affective_context", aff_ctx)
println("[soul] affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
} }
} }
@@ -310,20 +275,43 @@ fn emit_session_start_event() -> Void {
} }
let ts: Int = time_now() let ts: Int = time_now()
// Load previous session summary at boot stash in state for session_preload (issue #6). // Load previous session summary at boot stash in state for session_preload.
// Primary: label-based. Fallback: vector search. Logs it so continuity is auditable. // Search by label text + type, filter by exact label match to avoid false positives.
let prev_sum_node: String = engram_get_node_by_label("session:summary") // engram_get_node_by_label is not a runtime builtin; engram_search_json is used instead.
let prev_sum_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null") let sum_boot_search: String = engram_search_json("session:summary SessionSummary", 5)
let prev_sum_content: String = if prev_sum_ok { let sum_boot_ok: Bool = !str_eq(sum_boot_search, "") && !str_eq(sum_boot_search, "[]")
json_get(prev_sum_node, "content") let prev_sum_content: String = if sum_boot_ok {
let sbs_total: Int = json_array_len(sum_boot_search)
let sbs_i: Int = 0
let sbs_found: String = ""
while sbs_i < sbs_total {
let sbs_node: String = json_array_get(sum_boot_search, sbs_i)
let sbs_label: String = json_get(sbs_node, "label")
let sbs_type: String = json_get(sbs_node, "node_type")
let sbs_content: String = json_get(sbs_node, "content")
let sbs_found = if str_eq(sbs_label, "session:summary") && str_eq(sbs_type, "SessionSummary") && !str_eq(sbs_content, "") {
if str_eq(sbs_found, "") { sbs_content } else { sbs_found }
} else { sbs_found }
let sbs_i = sbs_i + 1
}
if str_eq(sbs_found, "") {
let sum_fb: String = engram_search_json("SessionSummary previous-session", 2)
let sum_fb_ok: Bool = !str_eq(sum_fb, "") && !str_eq(sum_fb, "[]")
if sum_fb_ok {
let sfn: String = json_array_get(sum_fb, 0)
let sftype: String = json_get(sfn, "node_type")
let sfcontent: String = json_get(sfn, "content")
if str_eq(sftype, "SessionSummary") && !str_eq(sfcontent, "") { sfcontent } else { "" }
} else { "" }
} else { sbs_found }
} else { } else {
let sum_search: String = engram_search_json("SessionSummary session:summary previous-session", 2) let sum_fb2: String = engram_search_json("SessionSummary previous-session", 2)
let sum_srch_ok: Bool = !str_eq(sum_search, "") && !str_eq(sum_search, "[]") let sum_fb2_ok: Bool = !str_eq(sum_fb2, "") && !str_eq(sum_fb2, "[]")
if sum_srch_ok { if sum_fb2_ok {
let sn: String = json_array_get(sum_search, 0) let sfn2: String = json_array_get(sum_fb2, 0)
let stype: String = json_get(sn, "node_type") let sftype2: String = json_get(sfn2, "node_type")
let scontent: String = json_get(sn, "content") let sfcontent2: String = json_get(sfn2, "content")
if str_eq(stype, "SessionSummary") && !str_eq(scontent, "") { scontent } else { "" } if str_eq(sftype2, "SessionSummary") && !str_eq(sfcontent2, "") { sfcontent2 } else { "" }
} else { "" } } else { "" }
} }
let has_prev_sum: String = if str_eq(prev_sum_content, "") { "false" } else { "true" } let has_prev_sum: String = if str_eq(prev_sum_content, "") { "false" } else { "true" }
@@ -397,11 +385,8 @@ fn layered_cycle(raw_input: String) -> String {
let cont_status: String = json_get(continuity, "status") let cont_status: String = json_get(continuity, "status")
let cont_action: String = json_get(continuity, "action") let cont_action: String = json_get(continuity, "action")
// Store continuity status so imprint can adjust its response register. // Store continuity status so imprint can adjust its response register
// TODO(reliability #4): session_continuity is process-global; scope per session_id state_set("session_continuity", cont_status)
// when available to prevent cross-session bleed under concurrent layered_cycle calls.
let cont_key: String = if str_eq(session_id, "") { "session_continuity" } else { "session_continuity:" + session_id }
state_set(cont_key, cont_status)
// Identity anomaly: add a gentle verification cue to the input before imprint // Identity anomaly: add a gentle verification cue to the input before imprint
let guided: String = if str_eq(cont_action, "identity_check") { let guided: String = if str_eq(cont_action, "identity_check") {
@@ -424,53 +409,14 @@ fn layered_cycle(raw_input: String) -> String {
json_get(steward_result, "redirect_to") json_get(steward_result, "redirect_to")
} }
// L2c: affective context injection. // ISSUE 1: pre-LLM bell augmentation for layered_cycle path.
let lc_aff_cutoff: Int = time_now() - 259200 // safety_augment_system appends soft/hard directive to system prompt when bell fires,
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2) // ensuring LLM processes message WITH the safety directive -- not just post-output gate.
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]") // Stored in state as "layered_cycle_safety_system_addendum" for imprint_respond to use.
let lc_bell_note: String = if lc_has_bell { // TODO: wire directly when imprint_respond gains system_override param (imprint.el change).
let lb0: String = json_array_get(lc_bell_nodes, 0) // ISSUE 3 TODO: no semantic crisis detection. Keyword-only means signals that evade
let lb_c: String = json_get(lb0, "content") // the phrase list pass with zero augmentation. Semantic layer = separate decision.
let lbm: String = " | ts:"
let lbmp: Int = str_index_of(lb_c, lbm)
let lb_ts_raw: String = if lbmp >= 0 {
let lbs: Int = lbmp + str_len(lbm)
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
let lbn: Int = str_index_of(lbr, " | ")
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
} else {
let lbca: String = json_get(lb0, "created_at")
if str_eq(lbca, "") { json_get(lb0, "updated_at") } else { lbca }
}
let lb_ts: Int = if str_eq(lb_ts_raw, "") { 0 } else { str_to_int(lb_ts_raw) }
if lb_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User was in distress in a recent session.]" } else { "" }
} else { "" }
let lc_pos_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 2)
let lc_has_pos: Bool = !str_eq(lc_pos_nodes, "") && !str_eq(lc_pos_nodes, "[]")
let lc_pos_note: String = if lc_has_pos && str_eq(lc_bell_note, "") {
let lp0: String = json_array_get(lc_pos_nodes, 0)
let lp_c: String = json_get(lp0, "content")
let lpm: String = " | ts:"
let lpmp: Int = str_index_of(lp_c, lpm)
let lp_ts_raw: String = if lpmp >= 0 {
let lps: Int = lpmp + str_len(lpm)
let lpr: String = str_slice(lp_c, lps, str_len(lp_c))
let lpn: Int = str_index_of(lpr, " | ")
if lpn < 0 { lpr } else { str_slice(lpr, 0, lpn) }
} else {
let lpca: String = json_get(lp0, "created_at")
if str_eq(lpca, "") { json_get(lp0, "updated_at") } else { lpca }
}
let lp_ts: Int = if str_eq(lp_ts_raw, "") { 0 } else { str_to_int(lp_ts_raw) }
if lp_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User shared positive news in a recent session.]" } else { "" }
} else { "" }
let lc_affective_note: String = if !str_eq(lc_bell_note, "") { lc_bell_note } else { lc_pos_note }
// pre-LLM bell augmentation
let augmented_addendum: String = safety_augment_system("", raw_input) let augmented_addendum: String = safety_augment_system("", raw_input)
let augmented_addendum = if str_eq(lc_affective_note, "") { augmented_addendum } else {
if str_eq(augmented_addendum, "") { lc_affective_note } else { lc_affective_note + "\n" + augmented_addendum }
}
state_set("layered_cycle_safety_system_addendum", augmented_addendum) state_set("layered_cycle_safety_system_addendum", augmented_addendum)
// L3: imprint responds // L3: imprint responds