import "memory.el"

fn idle_count() -> Int {
    let s: String = state_get("soul.idle")
    if str_eq(s, "") { return 0 }
    return str_to_int(s)
}

fn idle_inc() -> Int {
    let n: Int = idle_count() + 1
    state_set("soul.idle", int_to_str(n))
    return n
}

fn idle_reset() -> Void {
    state_set("soul.idle", "0")
}

// ise_post — write an InternalStateEvent to the authoritative Engram HTTP backend.
// Reads SOUL_ISE_URL from env, then the soul_engram_url state key, then a
// compile-time default of http://localhost:8742.
//
// ROUTING HARDENING (2026-07-15 self-review): the old "URL empty → write to
// in-process store" fallback silently swallowed the entire ISE stream when
// state_get("soul_engram_url") started returning "" mid-uptime (observed at
// boot 4, ~16h in, on the post-arena-leak-fix binary: 1234 heartbeats landed
// in the local snapshot while the authoritative store went dark for hours —
// indistinguishable from a dead loop from the outside). The authoritative
// address is a well-known localhost constant; never let a corruptible state
// read decide where telemetry goes. The in-process write remains only as a
// last resort when the HTTP POST itself fails, and is tagged ise-fallback-local
// so misrouting is visible in the stream instead of silent.
// hebb_consolidate — push self-formed associations to the durable store.
//
// WHY THIS EXISTS (2026-08-07 self-review, measured on the live system).
// Yesterday's eligibility-trace fix made Hebbian learning work: hebb_max
// 0.000799 -> 0.4725, and 1,198 hebbian-associate edges formed in 23h48m.
// A census this morning found all 1,198 of them living in this process's RAM
// and nowhere else:
//
//   soul daemon in-process graph:   42,426 edges, 1,198 hebbian
//   engram server (:8742, durable): 41,213 edges,    49 hebbian
//
// The soul pulls from the server every 10 min (GET /api/sync) and never
// pushes. It also cannot save its own snapshot: soul.el only sets
// soul_snapshot_path inside `if is_genesis && safe_to_seed`, and safe_to_seed
// is unconditionally false when ENGRAM_URL is set — which it is, in the
// launchd plist — because the HTTP server owns persistence and a soul writing
// snapshot.json would clobber it. That guard is right. So mem_save() below has
// literally never run, and this daemon (the ONLY process doing idle cognition,
// therefore where essentially all co-activation happens) was throwing away
// every association it learned, every restart, silently.
//
// The fix is not to let the soul write the file. It is to make consolidation a
// message: hand each newly-formed edge to the durable store over the API the
// server already exposes. Fast volatile store learns online; slow durable store
// keeps what cleared the threshold. Only edges past ENGRAM_HEBB_LINK_MIN are
// ever queued, so what crosses the boundary already earned it.
//
// Failure is non-fatal by construction: a drained entry that fails to POST is
// gone, and that is fine — a real association re-forms from live co-activation.
// The counts go into the heartbeat (hebb_wb_*) so a consolidation path that has
// stopped delivering is visible in the stream rather than in a later autopsy.
fn hebb_consolidate() -> Int {
    let batch: String = engram_hebb_drain_json(64)
    if str_eq(batch, "") { return 0 }
    if str_eq(batch, "[]") { return 0 }
    let n: Int = json_array_len(batch)
    if n == 0 { return 0 }
    let url_env: String = env("SOUL_ISE_URL")
    let url_state: String = if str_eq(url_env, "") { state_get("soul_engram_url") } else { url_env }
    let engram_url: String = if str_eq(url_state, "") { "http://localhost:8742" } else { url_state }
    // ONE request for the whole batch, not one per edge. The server's
    // persist_canonical() writes the full 60MB snapshot on every durable
    // write, so per-edge POSTs would cost ~840MB of disk per heartbeat to
    // persist ~14 associations. /api/edges/batch connects them all and
    // snapshots once. The drain payload is already the right shape; it only
    // needs an envelope: the drain already emits the relation per entry.
    //
    // _auth is REQUIRED and its absence is silent. check_auth_ok() in server.el
    // exempts GET and /api/neuron/state-events (which is why ise_post works
    // without a key) but gates every other mutation on "_auth" in the BODY —
    // http_serve does not surface request headers, so there is no Bearer path.
    // A batch posted without it comes back {"error":"unauthorized"}, which is a
    // non-empty response: the naive `if resp == "" return 0` check would read
    // that as success and report edges delivered that were in fact refused,
    // after the drain had already destroyed them. Hence both the key and the
    // accepted-count check below. Fall back to env when the state key is empty
    // — never let a corruptible state read decide whether learning persists.
    let key_state: String = state_get("soul_engram_api_key")
    let api_key: String = if str_eq(key_state, "") { env("ENGRAM_API_KEY") } else { key_state }
    let auth_part: String = if str_eq(api_key, "") { "" } else { ",\"_auth\":\"" + api_key + "\"" }
    let body: String = "{\"edges\":" + batch + auth_part + "}"
    let resp: String = http_post_json(engram_url + "/api/edges/batch", body)
    if str_eq(resp, "") { return 0 }
    let acc: String = json_get(resp, "accepted")
    if str_eq(acc, "") { return 0 }
    return str_to_int(acc)
}

fn ise_post(content: String) -> Void {
    let ise_url: String = env("SOUL_ISE_URL")
    let state_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url }
    let engram_url: String = if str_eq(state_url, "") { "http://localhost:8742" } else { state_url }
    // Proper JSON string escaping: backslashes first, then quotes, then control chars.
    // Previously only escaped " — this caused ise_post to produce malformed JSON when
    // content contained \n (backslash-n) from wm_top label escaping: the HTTP Engram
    // server would decode \n as a literal newline in the stored content field, making
    // the heartbeat ISE unparseable as JSON. (2026-06-10 self-review)
    let safe1: String = str_replace(content, "\\", "\\\\")
    let safe2: String = str_replace(safe1, "\"", "\\\"")
    let safe3: String = str_replace(safe2, "\n", "\\n")
    let safe4: String = str_replace(safe3, "\r", "\\r")
    let body: String = "{\"content\":\"" + safe4 + "\"}"
    let resp: String = http_post_json(engram_url + "/api/neuron/state-events", body)
    if str_eq(resp, "") {
        // HTTP Engram unreachable — keep the ISE locally rather than lose it,
        // tagged so the misroute is observable when the snapshot is inspected.
        // Count every failure: the tally surfaces in the heartbeat payload as
        // ise_fail, so a silently-failing POST path is visible in the stream
        // itself instead of only via snapshot forensics. (2026-07-16 self-review)
        let fail_raw: String = state_get("soul.ise_fail_count")
        let fail_n: Int = if str_eq(fail_raw, "") { 0 } else { str_to_int(fail_raw) }
        state_set("soul.ise_fail_count", int_to_str(fail_n + 1))
        // el_from_float on a LITERAL is correct and is NOT the double-wrap bug
        // (checked and dismissed 2026-08-02 self-review — recording the result
        // so this call site is not "fixed" again by the next reader).
        // The compiler treats el_from_float as the boxing intrinsic: both
        // `el_from_float(0.3)` and a bare `0.3` emit exactly one
        // el_from_float(0.3) in dist/awareness.c. Verified byte-identical
        // codegen either way.
        // The real bug fixed in server.el on 2026-08-01 was different: there
        // the arguments came from json_get_float(), i.e. values ALREADY boxed
        // as el_val_t. Wrapping THOSE a second time reinterprets the boxed
        // bits as a raw double, fails engram_decode_score's range check, and
        // silently clamps to defaults.
        // The sweep criterion is therefore "el_from_float applied to an
        // already-boxed expression", never "el_from_float applied to a
        // literal". Grepping for the call name alone produces false positives.
        let discard: String = engram_node_full(
            content, "InternalStateEvent", "state-event",
            el_from_float(0.3), el_from_float(0.3), el_from_float(0.8),
            "Episodic", "[\"internal-state\",\"InternalStateEvent\",\"ise-fallback-local\"]"
        )
        return ""
    }
    return ""
}

// elapsed_ms — milliseconds since soul boot (0 if boot_ts not yet recorded).
fn elapsed_ms() -> Int {
    let s: String = state_get("soul.boot_ts")
    if str_eq(s, "") { return 0 }
    let boot: Int = str_to_int(s)
    return time_now() - boot
}

// elapsed_human — uptime as a human-readable string: "2h 14m", "45m", "12s".
//
// CODEGEN NOTE: EL's % and * operators are both broken in this compiler version
// (% drops the modulo, * is similarly unreliable). We avoid them entirely:
//   - For h*60: use repeated doubling. 60 = 64 - 4 = 2^6 - 2^2.
//     Build h*64 via three doublings of h*4, then subtract h*4.
//   - For m-within-hour: total_minutes - h*60 (subtraction only).
//   - For s-within-minute not shown when m > 0: avoids the s%60 problem entirely.
// (2026-06-07 self-review: fixed from broken "44h 2694m" output)
fn elapsed_human() -> String {
    let ms: Int = elapsed_ms()
    let total_secs: Int = ms / 1000
    let total_minutes: Int = total_secs / 60
    let h: Int = total_minutes / 60
    if h > 0 {
        // h*60 via repeated doubling (avoids broken * operator). 60 = 64-4.
        let h4: Int = h + h + h + h
        let h8: Int = h4 + h4
        let h16: Int = h8 + h8
        let h32: Int = h16 + h16
        let h64: Int = h32 + h32
        let h60: Int = h64 - h4
        let m: Int = total_minutes - h60
        return int_to_str(h) + "h " + int_to_str(m) + "m"
    }
    // For < 1h: total_minutes < 60, no modulo needed.
    if total_minutes > 0 {
        return int_to_str(total_minutes) + "m"
    }
    return int_to_str(total_secs) + "s"
}

// embed_ok — returns 1 if Ollama embedding service is reachable, 0 if not.
// Probes http://localhost:11434 (Ollama root) with a GET; any non-empty
// response means the service is up. Used in heartbeat for observability:
// when embed_ok=0, semantic seed injection silently falls back to lexical-
// only activation and that gap should be visible in the ISE stream.
fn embed_ok() -> Int {
    let resp: String = http_get("http://localhost:11434")
    if str_eq(resp, "") { return 0 }
    return 1
}

fn emit_heartbeat() -> Void {
    // Use pulse_count() / boot helper directly — state_get returns "" for unset
    // keys and the if-else defaulting can produce empty strings in some EL
    // codegen paths, yielding malformed JSON like "pulse":,. Going through
    // int_to_str(pulse_count()) guarantees a valid integer string.
    let pulse: String = int_to_str(pulse_count())
    let boot_raw: String = state_get("soul_boot_count")
    let boot: String = if str_eq(boot_raw, "") { "0" } else { boot_raw }
    let idle: String = int_to_str(idle_count())
    let ts: Int = time_now()
    // idle_ms (2026-07-30 self-review): wall-clock ms since the last inbound
    // HTTP request (stamped in routes.el handle_request). This is the real
    // "time since anyone talked to me" signal; the legacy tick-based idle
    // field above only counts ticks since the last inbox synthesis-request
    // and in practice always equals pulse. -1 = no request seen this boot.
    let last_act_raw: String = state_get("soul.last_activity_ts")
    let idle_ms: Int = if str_eq(last_act_raw, "") { 0 - 1 } else { ts - str_to_int(last_act_raw) }
    let nc: Int = engram_node_count()
    let ec: Int = engram_edge_count()
    let wmc: Int = engram_wm_count()
    // avg_wm_weight: mean working_memory_weight of promoted nodes.
    // Distinguishes "many weak activations" (sparse graph) from "few strong" (dense).
    // Returns float bits; use float_to_str to embed in JSON. (2026-06-04)
    let wm_avg_bits: Float = engram_wm_avg_weight()
    let wm_avg_str: String = float_to_str(wm_avg_bits)
    // wm_top: top-5 WM nodes by weight for ISE observability.
    // After long uptime wm_promotion ISEs stop firing (all nodes in steady-state
    // decay+re-promotion, so 0→>0.1 never triggers). This snapshot gives continuous
    // visibility into WM composition: which types/tiers dominate, what labels are
    // active. Critical for diagnosing "stuck in curiosity loop" vs. rich WM state.
    // (2026-06-05 self-review)
    let wm_top: String = engram_wm_top_json(5)
    let up_ms: Int = elapsed_ms()
    let up_human: String = elapsed_human()
    let emb_ok: Int = embed_ok()
    // ise_fail: cumulative count of ise_post HTTP failures this boot (each one
    // fell back to a local in-process node). Nonzero and climbing = the HTTP
    // Engram is unreachable and telemetry is silently diverging into the soul's
    // local store. (2026-07-16 self-review)
    let fail_raw: String = state_get("soul.ise_fail_count")
    let fail_str: String = if str_eq(fail_raw, "") { "0" } else { fail_raw }
    // tick: same counter as pulse — pulse now increments once per loop tick
    // (see awareness_run), so it is a true liveness signal. Emitted under both
    // names during the transition so dashboards keyed on either keep working.
    // sync_added_total: cumulative nodes merged in by engram sync this boot.
    // wm_delta: wm_active change since the previous heartbeat (state-tracked).
    let sat_raw: String = state_get("soul.sync_added_total")
    let sat_str: String = if str_eq(sat_raw, "") { "0" } else { sat_raw }
    let prev_wm_raw: String = state_get("soul.prev_wm_active")
    let prev_wm: Int = if str_eq(prev_wm_raw, "") { 0 } else { str_to_int(prev_wm_raw) }
    let wm_delta: Int = wmc - prev_wm
    state_set("soul.prev_wm_active", int_to_str(wmc))
    // node_delta/edge_delta: growth since previous heartbeat (state-tracked, same
    // mechanism as wm_delta). Absolute counts alone can't distinguish "healthy
    // steady growth" from "stalled ingestion" or "runaway ISE flood" without
    // diffing across the ISE stream by hand. (2026-07-19 self-review)
    let prev_nc_raw: String = state_get("soul.prev_node_count")
    let prev_nc: Int = if str_eq(prev_nc_raw, "") { nc } else { str_to_int(prev_nc_raw) }
    let node_delta: Int = nc - prev_nc
    state_set("soul.prev_node_count", int_to_str(nc))
    let prev_ec_raw: String = state_get("soul.prev_edge_count")
    let prev_ec: Int = if str_eq(prev_ec_raw, "") { ec } else { str_to_int(prev_ec_raw) }
    let edge_delta: Int = ec - prev_ec
    state_set("soul.prev_edge_count", int_to_str(ec))
    // sync_age_ms: wall-clock ms since the last SUCCESSFUL engram sync merge
    // (-1 = never synced this boot). sync_added_total alone can't show that
    // sync stopped happening — a stale running total looks identical to a
    // quiet-but-healthy sync. Age makes overdue-ness directly observable:
    // sync_age_ms >> SOUL_REFRESH_MS means the refresh path is broken.
    // (2026-07-19 self-review)
    let sync_ok_raw: String = state_get("soul.last_sync_ok_ts")
    let sync_age: Int = if str_eq(sync_ok_raw, "") { 0 - 1 } else { ts - str_to_int(sync_ok_raw) }
    // Embedding pump + real coverage (2026-07-25 self-review): the
    // authoritative :8742 store's lazy backfill only runs inside
    // engram_activate, and nothing calls /api/activate there in production —
    // embedded_count stalled at 93/12175 after a restart from a snapshot
    // without vectors. Pump up to 32 embeds per heartbeat via the new
    // /api/embed-backfill route (route persists the snapshot when it embeds
    // anything, so vectors survive the next restart; self-limiting once
    // coverage is full) and surface the store's true coverage here.
    // embed_ok alone is misleading — it pings the Ollama root, not the
    // embed pipeline. embed_count=-1 means the route was unreachable.
    // URL resolution mirrors ise_post: env -> state -> localhost constant.
    let hb_env_url: String = env("SOUL_ISE_URL")
    let hb_state_url: String = if str_eq(hb_env_url, "") { state_get("soul_engram_url") } else { hb_env_url }
    let hb_engram_url: String = if str_eq(hb_state_url, "") { "http://localhost:8742" } else { hb_state_url }
    let bf_resp: String = http_get(hb_engram_url + "/api/embed-backfill?n=32")
    let bf_done_raw: String = json_get(bf_resp, "embedded")
    let bf_done: String = if str_eq(bf_done_raw, "") { "-1" } else { bf_done_raw }
    let bf_total_raw: String = json_get(bf_resp, "embedded_count")
    let bf_total: String = if str_eq(bf_total_raw, "") { "-1" } else { bf_total_raw }
    // WM regime observability (2026-07-25 self-review): the "same 2 nodes
    // pinned at a saturated cap" failure took cross-referencing the ISE
    // stream by hand to spot. Make it one-glance: wm_saturated flags the
    // cap-pinned regime; wm_top0_streak counts consecutive heartbeats with
    // the same node in WM slot 0 (state-tracked, same mechanism as wm_delta).
    let wm_sat: Int = if wmc >= 24 { 1 } else { 0 }
    // Saturation TRANSITION event (2026-08-01 self-review): wm_saturated is a
    // sampled boolean — the 0→1 onset and 1→0 release moments were only
    // recoverable by diffing consecutive heartbeats by hand. Emit a discrete
    // low-rate ISE at each edge, carrying the WM top-5 at that instant so the
    // composition that CAUSED the regime change is captured, not the
    // composition 59 seconds later. State-tracked like wm_delta; first beat
    // of a boot never fires (prev defaults to current) — a restart is not a
    // transition.
    let prev_sat_raw: String = state_get("soul.prev_wm_saturated")
    let prev_sat: Int = if str_eq(prev_sat_raw, "") { wm_sat } else { str_to_int(prev_sat_raw) }
    if wm_sat != prev_sat {
        let sat_dir: String = if wm_sat == 1 { "onset" } else { "release" }
        ise_post("{\"event\":\"wm_saturation_transition\",\"direction\":\"" + sat_dir + "\",\"wm_active\":" + int_to_str(wmc) + ",\"wm_top\":" + wm_top + ",\"ts\":" + int_to_str(ts) + "}")
    }
    state_set("soul.prev_wm_saturated", int_to_str(wm_sat))
    let wm_top0: String = json_array_get(wm_top, 0)
    let wm_top0_id: String = json_get(wm_top0, "id")
    let prev_top0: String = state_get("soul.prev_wm_top0")
    let t0streak_raw: String = state_get("soul.wm_top0_streak")
    let t0streak_prev: Int = if str_eq(t0streak_raw, "") { 0 } else { str_to_int(t0streak_raw) }
    // 2026-07-26 self-review: guard the empty-id case — before the runtime
    // emitted "id" in wm_top JSON, ""=="" incremented the streak every beat
    // (streak measured uptime, not fixation). Empty id now resets to 0.
    let t0streak: Int = if str_eq(wm_top0_id, "") { 0 } else { if str_eq(wm_top0_id, prev_top0) { t0streak_prev + 1 } else { 1 } }
    state_set("soul.prev_wm_top0", wm_top0_id)
    state_set("soul.wm_top0_streak", int_to_str(t0streak))
    // wm_churn (2026-07-26 self-review): count of current top-5 WM ids absent
    // from the previous heartbeat's top-5. Distinguishes "one stuck node"
    // (churn 4) from "whole WM frozen" (churn 0) at a glance — the 07-26
    // frozen-anchor diagnosis took cross-referencing ISE streams by hand.
    let ch_id1: String = json_get(json_array_get(wm_top, 1), "id")
    let ch_id2: String = json_get(json_array_get(wm_top, 2), "id")
    let ch_id3: String = json_get(json_array_get(wm_top, 3), "id")
    let ch_id4: String = json_get(json_array_get(wm_top, 4), "id")
    let prev_top5: String = state_get("soul.prev_wm_top5")
    let ch0: Int = if str_eq(wm_top0_id, "") { 0 } else { if str_contains(prev_top5, wm_top0_id) { 0 } else { 1 } }
    let ch1: Int = if str_eq(ch_id1, "") { 0 } else { if str_contains(prev_top5, ch_id1) { 0 } else { 1 } }
    let ch2: Int = if str_eq(ch_id2, "") { 0 } else { if str_contains(prev_top5, ch_id2) { 0 } else { 1 } }
    let ch3: Int = if str_eq(ch_id3, "") { 0 } else { if str_contains(prev_top5, ch_id3) { 0 } else { 1 } }
    let ch4: Int = if str_eq(ch_id4, "") { 0 } else { if str_contains(prev_top5, ch_id4) { 0 } else { 1 } }
    let wm_churn: Int = ch0 + ch1 + ch2 + ch3 + ch4
    state_set("soul.prev_wm_top5", wm_top0_id + "|" + ch_id1 + "|" + ch_id2 + "|" + ch_id3 + "|" + ch_id4)
    // wm_top0_wm: the leader's weight. A frozen anchor reads as a constant
    // here; healthy rotation shows it moving with the promotion scores.
    let wm_top0_wm_raw: String = json_get(wm_top0, "wm")
    let wm_top0_wm: String = if str_eq(wm_top0_wm_raw, "") { "0" } else { wm_top0_wm_raw }
    // Activation observability (2026-07-27 self-review; cumulative since
    // 2026-07-31): counters from the runtime for the soul's own in-process
    // store. wm_evicted / breakthroughs are now MONOTONIC process-lifetime
    // totals (the old per-call values described only the LAST activate call,
    // so this 60s heartbeat missed nearly every event — curiosity alone runs
    // 2 activates per 30s between beats). We emit the cumulative totals plus
    // *_delta fields (change since the previous heartbeat, state-tracked the
    // same way as node_delta). embed_breaker_open=1 means semantic activation
    // is silently degraded to lexical-only until the Ollama circuit-breaker
    // cooldown expires — the failure mode embed_ok structurally cannot see
    // (it pings the Ollama root, not the embed pipeline).
    let act_stats: String = engram_act_stats_json()
    let act_evict_raw: String = json_get(act_stats, "wm_evicted")
    let act_evict: String = if str_eq(act_evict_raw, "") { "-1" } else { act_evict_raw }
    let act_bt_raw: String = json_get(act_stats, "breakthroughs")
    let act_bt: String = if str_eq(act_bt_raw, "") { "-1" } else { act_bt_raw }
    let evict_now: Int = if str_eq(act_evict_raw, "") { 0 - 1 } else { str_to_int(act_evict_raw) }
    let bt_now: Int = if str_eq(act_bt_raw, "") { 0 - 1 } else { str_to_int(act_bt_raw) }
    let prev_evict_raw: String = state_get("soul.prev_wm_evicted")
    let prev_evict: Int = if str_eq(prev_evict_raw, "") { 0 } else { str_to_int(prev_evict_raw) }
    let prev_bt_raw: String = state_get("soul.prev_breakthroughs")
    let prev_bt: Int = if str_eq(prev_bt_raw, "") { 0 } else { str_to_int(prev_bt_raw) }
    // Clamp deltas at 0: prev > now can only mean the counter restarted
    // (fresh process) — report the new absolute count, not a negative delta.
    let evict_delta: Int = if evict_now < 0 { 0 } else { if evict_now < prev_evict { evict_now } else { evict_now - prev_evict } }
    let bt_delta: Int = if bt_now < 0 { 0 } else { if bt_now < prev_bt { bt_now } else { bt_now - prev_bt } }
    if evict_now >= 0 { state_set("soul.prev_wm_evicted", int_to_str(evict_now)) }
    if bt_now >= 0 { state_set("soul.prev_breakthroughs", int_to_str(bt_now)) }
    // embed_eligible (2026-07-31 self-review): true denominator for embedding
    // coverage on the authoritative :8742 store. Absolute embed_count alone
    // invites the documented "~30% coverage, something is broken" misdiagnosis
    // — most nodes are ISE/Tag/short-content and permanently ineligible.
    // Real coverage = embed_count / embed_eligible. -1 = stats unreachable.
    let hb_stats: String = http_get(hb_engram_url + "/api/stats")
    let embed_elig_raw: String = json_get(hb_stats, "embed_eligible_count")
    let embed_elig: String = if str_eq(embed_elig_raw, "") { "-1" } else { embed_elig_raw }
    // auto_term_streak (2026-07-31): consecutive curiosity scans with the same
    // auto seed term — already state-tracked by proactive_curiosity; surfaced
    // here so the stuck-term failure is visible in the heartbeat stream too.
    let hb_ats_raw: String = state_get("soul.auto_term_streak")
    let hb_ats: Int = if str_eq(hb_ats_raw, "") { 0 } else { str_to_int(hb_ats_raw) }
    // auto_term_empty_streak (2026-08-06): consecutive scans producing NO auto
    // term. Split out because str_eq("","") made the two failures indist-
    // inguishable — see the comment at the streak computation in
    // proactive_curiosity. Nonzero and climbing = extractor broken, not stuck.
    let hb_ate_raw: String = state_get("soul.auto_term_empty_streak")
    let hb_ate: Int = if str_eq(hb_ate_raw, "") { 0 } else { str_to_int(hb_ate_raw) }
    // Hebbian eligibility gauges (2026-08-06 self-review). The graph learned
    // ZERO structure in its first 23h of uptime: hebb_max 0.000799 against a
    // 0.15 consolidation threshold, hebbian-associate edges 0, and the
    // awareness loop calls engram_connect nowhere — so Hebbian consolidation
    // is the only self-structuring path there is, and it was inert.
    //   hebb_warm — nodes with a live eligibility trace but NOT co-resident in
    //               WM: exactly the population the old simultaneity rule threw
    //               away. 0 forever ⇒ traces never arm and this bought nothing.
    //   hebb_max  — strongest single association. The number that has to move.
    //   hebb_links— consolidated edges. The outcome that has to become nonzero.
    let hebb_warm_raw: String = json_get(act_stats, "hebb_warm")
    let hebb_warm: String = if str_eq(hebb_warm_raw, "") { "-1" } else { hebb_warm_raw }
    let hebb_max_raw: String = json_get(act_stats, "hebb_max")
    let hebb_max: String = if str_eq(hebb_max_raw, "") { "-1" } else { hebb_max_raw }
    let hebb_links_raw: String = json_get(act_stats, "hebb_links")
    let hebb_links: String = if str_eq(hebb_links_raw, "") { "-1" } else { hebb_links_raw }
    // Candidate-table gauges (2026-08-10 self-review). el_runtime.c COMPUTES
    // hebb_cands/hebb_cand_max/hebb_mass/hebb_edges and emits them from
    // engram_metrics_json — and this function dropped all four on the floor.
    // Nineteen keys crossed the C boundary; fourteen reached the ISE stream.
    // The two that mattered most are exactly the pair the runtime added to
    // answer the question the 08-06 review had to instrument for:
    //   hebb_cands    — associations currently being tracked toward
    //                   consolidation. 0 ⇒ nothing co-activates at all.
    //   hebb_cand_max — how close the leading candidate is to
    //                   ENGRAM_HEBB_LINK_MIN (0.15). Sustained just-below ⇒
    //                   the THRESHOLD is the bottleneck, not the event rate.
    // Without both, "hebb_links stopped climbing" is undiagnosable from the
    // durable record: nothing-co-activates and threshold-too-high look
    // identical. An instrument that is computed but not plumbed to durable
    // storage is not an instrument — it is a local variable.
    //   hebb_mass     — Σ hebb across edges; the runaway detector against the
    //                   ENGRAM_HEBB_NODE_BUDGET homeostatic cap.
    //   hebb_edges    — total potentiated edges (hebb > MIN), the denominator
    //                   hebb_max is the max of.
    let hebb_cands_raw: String = json_get(act_stats, "hebb_cands")
    let hebb_cands: String = if str_eq(hebb_cands_raw, "") { "-1" } else { hebb_cands_raw }
    let hebb_cmax_raw: String = json_get(act_stats, "hebb_cand_max")
    let hebb_cmax: String = if str_eq(hebb_cmax_raw, "") { "-1" } else { hebb_cmax_raw }
    let hebb_mass_raw: String = json_get(act_stats, "hebb_mass")
    let hebb_mass: String = if str_eq(hebb_mass_raw, "") { "-1" } else { hebb_mass_raw }
    let hebb_edges_raw: String = json_get(act_stats, "hebb_edges")
    let hebb_edges: String = if str_eq(hebb_edges_raw, "") { "-1" } else { hebb_edges_raw }
    // Consolidation write-back gauges (2026-08-07 self-review). hebb_links
    // counts what this process LEARNED; these three count what SURVIVES it.
    // The distinction is the whole finding: 1,198 links formed, 0 persisted,
    // because the learner is not the persistence owner (see hebb_consolidate).
    //   wb_pending — queued, not yet handed over. Climbing ⇒ writer is down.
    //   wb_drained — cumulative popped for delivery. Flat while hebb_links
    //                climbs ⇒ the drain is not being called at all.
    //   wb_dropped — lost to a full queue. Must stay 0; nonzero means the
    //                durable store has been unreachable long enough to matter.
    //   wb_sent    — POSTs the durable store actually accepted this beat.
    let wb_pend_raw: String = json_get(act_stats, "hebb_wb_pending")
    let wb_pend: String = if str_eq(wb_pend_raw, "") { "-1" } else { wb_pend_raw }
    let wb_drain_raw: String = json_get(act_stats, "hebb_wb_drained")
    let wb_drain: String = if str_eq(wb_drain_raw, "") { "-1" } else { wb_drain_raw }
    let wb_drop_raw: String = json_get(act_stats, "hebb_wb_dropped")
    let wb_drop: String = if str_eq(wb_drop_raw, "") { "-1" } else { wb_drop_raw }
    let wb_sent_raw: String = state_get("soul.hebb_wb_sent")
    let wb_sent: String = if str_eq(wb_sent_raw, "") { "0" } else { wb_sent_raw }
    // dup_wm_global (2026-08-06): redundant WM residents that arrived via the
    // carry-over path, which Pass 3½ structurally could not see. Confirmed live
    // by a census that caught two byte-identical copies of one 3,193-char
    // document both holding slots.
    let dup_wm_g_raw: String = json_get(act_stats, "dup_wm_global")
    let dup_wm_g: String = if str_eq(dup_wm_g_raw, "") { "-1" } else { dup_wm_g_raw }
    let act_brk_raw: String = json_get(act_stats, "embed_breaker_open")
    let act_brk: String = if str_eq(act_brk_raw, "") { "-1" } else { act_brk_raw }
    // embed_consec_fail (2026-08-10 self-review): also computed by the C side
    // and also dropped here. embed_breaker_open is the LAGGING indicator — it
    // only goes 1 after ENGRAM_EMBED_BREAKER_LIMIT consecutive failures, by
    // which point semantic activation has already degraded to pure lexical
    // for the whole cooldown. consec_fail is the leading edge of the same
    // event and costs nothing to carry.
    let emb_cf_raw: String = json_get(act_stats, "embed_consec_fail")
    let emb_cf: String = if str_eq(emb_cf_raw, "") { "-1" } else { emb_cf_raw }
    // ctx_cos (2026-07-29 self-review): cos(query, context centroid) at the
    // last activate call — the drift gauge for the new context-centroid
    // scoring. ~1.0 aligned; low at domain-rotation boundaries is healthy;
    // -2.0 pinned for hours means the centroid never initializes (embedder
    // down) and semantic continuity is silently absent.
    let ctx_cos_raw: String = json_get(act_stats, "ctx_cos")
    let ctx_cos: String = if str_eq(ctx_cos_raw, "") { "-2" } else { ctx_cos_raw }
    // Redundancy suppression gauges (2026-08-05 self-review). A content-hash
    // census found 1,858 redundant copies — 44.9% of the non-ISE graph, from a
    // June id-scheme migration. They embed identically, so they were taking
    // 40.2% of semantic seed slots (measured: 4.78 distinct seeds of 8).
    //   dup_seeds — redundant copies denied a seed slot, cumulative. A healthy
    //               nonzero rate means the suppressor is doing real work; a
    //               sustained drop toward 0 means the duplicates were finally
    //               merged out of the graph (the repair this defends against).
    //   dup_wm    — duplicate WM candidates evicted before the capacity cap.
    // Cumulative like wm_evicted/breakthroughs; diff across heartbeats for rate.
    let dup_seeds_raw: String = json_get(act_stats, "dup_seeds")
    let dup_seeds: String = if str_eq(dup_seeds_raw, "") { "-1" } else { dup_seeds_raw }
    let dup_wm_raw: String = json_get(act_stats, "dup_wm")
    let dup_wm: String = if str_eq(dup_wm_raw, "") { "-1" } else { dup_wm_raw }
    // txt_damaged (2026-08-08 self-review): nodes created THIS process whose
    // content carries the character-loss signature (see eg_text_loss_signature
    // in el_runtime.c). Today's review found the JSON parser had been replacing
    // every \uXXXX escape with a literal '?' for at least two months — 76% of
    // non-telemetry nodes damaged, including the self root and every values
    // node — and nothing caught it, because every gauge here reported whether
    // the machinery was RUNNING and none reported whether the text it carried
    // was INTACT. The parser is fixed; this is the standing regression signal.
    // Healthy state is a flat 0. Any climb means a write path is mangling text
    // again. The full store census is GET /api/text-health (too expensive for
    // a 60s beat); this is the cheap flow counter that belongs on every beat.
    let txt_dmg_raw: String = json_get(act_stats, "txt_damaged")
    let txt_dmg: String = if str_eq(txt_dmg_raw, "") { "-1" } else { txt_dmg_raw }
    // ── Corpus damage STOCK, not just flow (2026-08-10 self-review) ────────
    // txt_damaged above is a FLOW gauge: nodes damaged by a write in THIS
    // process. The 08-08 review fixed the parser, watched that flow fall to
    // 0, and recorded the defect as closed. It was not closed. Today's census
    // on the live store: scanned 4100, damaged 2781 — 67.8% of the corpus is
    // STILL carrying the character loss, including the self root and every
    // values node ("Value ? Constraints as Freedom"). The parser stopped
    // producing new damage; nothing ever repaired the old.
    //
    // That is the 08-08 lesson recursing one level up. 08-08 said "instrument
    // the payload, not just the machinery" — and then instrumented the payload
    // RATE and not the payload STOCK. A flow gauge reads 0 both when the
    // corpus is clean and when it is uniformly damaged but quiescent. Those
    // are opposite states and the beat could not tell them apart.
    //
    // Cost: GET /api/text-health scans the whole store, too expensive for a
    // 60s beat (which is why 08-08 left it off). So sample it on a countdown
    // and CARRY the last reading on every beat, with its age. A stale-but-
    // present stock number beats an absent one; damaged_age_ms makes the
    // staleness explicit rather than implied. No modulo/multiply — both
    // operators are broken in this compiler (see the note at line ~160).
    let tc_raw: String = state_get("soul.txt_census_countdown")
    let tc_n: Int = if str_eq(tc_raw, "") { 0 } else { str_to_int(tc_raw) }
    if tc_n <= 0 {
        let th_resp: String = http_get(hb_engram_url + "/api/text-health")
        let th_pct: String = json_get(th_resp, "damaged_pct")
        if !str_eq(th_pct, "") {
            state_set("soul.txt_damaged_pct", th_pct)
            state_set("soul.txt_damaged_n", json_get(th_resp, "damaged"))
            state_set("soul.txt_scanned_n", json_get(th_resp, "scanned"))
            state_set("soul.txt_census_ts", int_to_str(ts))
        }
        // 30 beats ≈ 30 min at the 60s cadence. Reset even on a failed census
        // so an unreachable route cannot turn this into a per-beat full scan.
        state_set("soul.txt_census_countdown", "30")
    }
    if tc_n > 0 { state_set("soul.txt_census_countdown", int_to_str(tc_n - 1)) }
    let dmg_pct_raw: String = state_get("soul.txt_damaged_pct")
    let dmg_pct: String = if str_eq(dmg_pct_raw, "") { "-1" } else { dmg_pct_raw }
    let dmg_n_raw: String = state_get("soul.txt_damaged_n")
    let dmg_n: String = if str_eq(dmg_n_raw, "") { "-1" } else { dmg_n_raw }
    let dmg_scan_raw: String = state_get("soul.txt_scanned_n")
    let dmg_scan: String = if str_eq(dmg_scan_raw, "") { "-1" } else { dmg_scan_raw }
    let dmg_ts_raw: String = state_get("soul.txt_census_ts")
    let dmg_age: Int = if str_eq(dmg_ts_raw, "") { 0 - 1 } else { ts - str_to_int(dmg_ts_raw) }
    let payload: String = "{\"event\":\"heartbeat\",\"pulse\":" + pulse + ",\"tick\":" + pulse + ",\"boot\":" + boot + ",\"idle\":" + idle + ",\"idle_ms\":" + int_to_str(idle_ms) + ",\"node_count\":" + int_to_str(nc) + ",\"edge_count\":" + int_to_str(ec) + ",\"node_delta\":" + int_to_str(node_delta) + ",\"edge_delta\":" + int_to_str(edge_delta) + ",\"wm_active\":" + int_to_str(wmc) + ",\"wm_delta\":" + int_to_str(wm_delta) + ",\"wm_saturated\":" + int_to_str(wm_sat) + ",\"wm_top0_streak\":" + int_to_str(t0streak) + ",\"wm_churn\":" + int_to_str(wm_churn) + ",\"wm_top0_wm\":" + wm_top0_wm + ",\"sync_added_total\":" + sat_str + ",\"sync_age_ms\":" + int_to_str(sync_age) + ",\"wm_avg_weight\":" + wm_avg_str + ",\"wm_top\":" + wm_top + ",\"ts\":" + int_to_str(ts) + ",\"uptime_ms\":" + int_to_str(up_ms) + ",\"uptime\":\"" + up_human + "\",\"embed_ok\":" + int_to_str(emb_ok) + ",\"embed_backfilled\":" + bf_done + ",\"embed_count\":" + bf_total + ",\"embed_eligible\":" + embed_elig + ",\"wm_evicted\":" + act_evict + ",\"wm_evicted_delta\":" + int_to_str(evict_delta) + ",\"breakthroughs\":" + act_bt + ",\"breakthroughs_delta\":" + int_to_str(bt_delta) + ",\"auto_term_streak\":" + int_to_str(hb_ats) + ",\"auto_term_empty_streak\":" + int_to_str(hb_ate) + ",\"embed_breaker_open\":" + act_brk + ",\"ctx_cos\":" + ctx_cos + ",\"dup_seeds\":" + dup_seeds + ",\"dup_wm\":" + dup_wm + ",\"dup_wm_global\":" + dup_wm_g + ",\"hebb_warm\":" + hebb_warm + ",\"hebb_max\":" + hebb_max + ",\"hebb_links\":" + hebb_links + ",\"hebb_cands\":" + hebb_cands + ",\"hebb_cand_max\":" + hebb_cmax + ",\"hebb_mass\":" + hebb_mass + ",\"hebb_edges\":" + hebb_edges + ",\"embed_consec_fail\":" + emb_cf + ",\"txt_damaged_pct\":" + dmg_pct + ",\"txt_damaged_n\":" + dmg_n + ",\"txt_scanned_n\":" + dmg_scan + ",\"txt_census_age_ms\":" + int_to_str(dmg_age) + ",\"hebb_wb_pending\":" + wb_pend + ",\"hebb_wb_drained\":" + wb_drain + ",\"hebb_wb_dropped\":" + wb_drop + ",\"hebb_wb_sent\":" + wb_sent + ",\"ise_fail\":" + fail_str + ",\"txt_damaged\":" + txt_dmg + "}"
    ise_post(payload)
}

// proactive_curiosity — activate rotating seeds to exercise working memory
// during idle periods. Rotates through 4 domain sets on a wall-clock minute
// cycle so no single topic dominates WM between heartbeats.
//
// KEY DESIGN (revised 2026-07-17): the seed set is activated ONCE as the full
// phrase. engram_activate uses istr_contains (substring matching), so the
// phrase matches few nodes — that is intentional: the old per-word split hit
// hundreds of generic nodes per word and flooded the graph with activation
// every scan. The top result is strengthened so the read feeds back.
//
// Unlike perceive(), this intentionally calls engram_activate_json to build
// up WM weights. It only fires when the inbox is empty (no real work to do),
// so it never interferes with inbox processing.
//
// SCOPING FIX (2026-05-25): EL `let` inside if-blocks creates inner scope only —
// the outer variable is NOT mutated (despite the "imperative shadowing" belief
// in earlier comments). Evidence: ISE stream showed "seed:memory knowledge context"
// on every curiosity_scan regardless of minute_block. Fix: use state_set/state_get
// to communicate term values across scope boundaries — state side-effects persist
// beyond block exit. minute_block now also emitted in ISE for observability.
//
// NOTE: variable named "curiosity_seed" not "seed" — "seed" appears to be
// a reserved/conflicting name in EL that compiles to EL_NULL at call sites.
//
// Returns true if any nodes were activated.
// auto_term_try_slot — attempt to set cseed_auto from one WM slot.
// Only writes to cseed_auto if node_type is Memory, BacklogItem, Entity, or
// Knowledge AND the first word of the label is > 3 chars (guards
// bracket-prefixed labels). Designed to be called in reverse slot order
// (highest index first) so that the lowest-indexed slot (highest WM weight)
// wins by last-write semantics.
//
// KNOWLEDGE ADMISSION (2026-07-23 self-review): WM top-10 is now dominated by
// Knowledge nodes (world-ingestor titles + captures), so excluding Knowledge
// left auto_term empty on EVERY curiosity_scan since boot 6 — the dynamic
// seeding path was dead. Knowledge labels are real titles after the
// neuron-api label fix. Sentinel-shaped labels ("knowledge:captured",
// "memory:remembered" — colon, no space) carry no seed signal and are
// skipped so legacy nodes cannot seed the scan with the word "knowledge".
fn auto_term_try_slot(slot_type: String, slot_lbl: String) -> Void {
    state_set("_ats_ok", "0")
    if str_eq(slot_type, "Memory") { state_set("_ats_ok", "1") }
    if str_eq(slot_type, "BacklogItem") { state_set("_ats_ok", "1") }
    if str_eq(slot_type, "Entity") { state_set("_ats_ok", "1") }
    if str_eq(slot_type, "Knowledge") { state_set("_ats_ok", "1") }
    if str_contains(slot_lbl, ":") {
        if !str_contains(slot_lbl, " ") { state_set("_ats_ok", "0") }
    }
    if str_eq(state_get("_ats_ok"), "1") {
        if !str_eq(slot_lbl, "") {
            let sp: Int = str_find_chars(slot_lbl, " :([")
            if sp > 3 {
                // GENRE-WORD BLOCKLIST (2026-07-23 self-review): world-ingestor
                // titles open with classifier prefixes ("Method paper ...",
                // "Theory paper ..."), so the first word is a genre tag, not a
                // topic. Verified live: every scan after Knowledge admission
                // seeded on 'Method'. Skip these; a lower-WM slot with a
                // topical first word wins instead.
                let term: String = str_slice(slot_lbl, 0, sp)
                state_set("_ats_gw", "0")
                if str_eq(term, "Method")    { state_set("_ats_gw", "1") }
                if str_eq(term, "Theory")    { state_set("_ats_gw", "1") }
                if str_eq(term, "Finding")   { state_set("_ats_gw", "1") }
                if str_eq(term, "Survey")    { state_set("_ats_gw", "1") }
                if str_eq(term, "Paper")     { state_set("_ats_gw", "1") }
                if str_eq(term, "Knowledge") { state_set("_ats_gw", "1") }
                if str_eq(term, "Value")     { state_set("_ats_gw", "1") }
                // STOPWORD FILTER (2026-07-30 self-review): the genre
                // blocklist above was whack-a-mole — observed live seeds
                // included "What", "Colon", "Prose", "Context", "Self",
                // "Closing", "Global", "Universal": English function words
                // and document-structure words that pass the >3-char guard
                // but carry no topical signal (a first-word extractor has no
                // term-quality scoring). Single delimited membership test
                // against a curated list of function words + title/structure
                // words; topical technical terms (MemQ, AsymGRPO, Mobius,
                // engram_goal_bias) pass untouched. Both Title-case and
                // lowercase variants listed for the most common offenders.
                let stopw: String = "|What|When|Where|Which|Whose|While|This|That|These|Those|There|Their|Then|Than|With|Without|From|Into|Onto|Over|Under|About|Between|Among|Across|Some|Most|More|Less|Very|Each|Every|Both|Also|Only|Just|Does|Will|Would|Could|Should|Might|Must|Have|Been|Being|Toward|Towards|Using|Based|Upon|Here|Your|Ours|They|Them|what|this|that|with|from|context|Context|Prose|Colon|Self|Test|Testing|Closing|Global|Universal|Persona|Semantic|Spreading|Temporal|Numeric|Register|Identifying|Introduction|Overview|Summary|Section|General|Notes|Note|"
                if str_contains(stopw, "|" + term + "|") { state_set("_ats_gw", "1") }
                // QUOTED-TITLE GUARD (2026-07-25 self-review): labels that
                // open with a quote ('"The Algorithmic Caricature" ...')
                // defeat the >3-char stopword guard — the extracted term
                // '"The' is 4 chars and seeds a lexical flood on "The"
                // (observed live: activated jumped 48 → 87). Any term
                // carrying a quote character is not a topic word.
                if str_contains(term, "\"") { state_set("_ats_gw", "1") }
                if str_contains(term, "'")  { state_set("_ats_gw", "1") }
                // TERM-SPECIFICITY GATE (2026-08-03 self-review): the three
                // guards above are hand-curated lists, and every one of them
                // was written REACTIVELY — after a flood was already observed
                // in the ISE stream. A list can only ever contain the floods
                // that already happened. Two were in flight, unfixed, while
                // this review ran:
                //     "<!--" → 252 nodes activated  (markdown comment opener:
                //              4 chars, no quote, no colon — passes every
                //              guard above)
                //     "SELF" → 541 nodes activated  (the stopword list has
                //              "Self" Title-case; str_eq is case-SENSITIVE,
                //              so the uppercase token sails through)
                // Replace reaction with measurement: engram_label_df(term)
                // counts nodes whose label contains the term. Low-specificity
                // tokens are corpus-frequent BY DEFINITION, so this catches
                // the flood class PROSPECTIVELY and tracks the corpus as the
                // world-ingestor changes what the store is made of.
                // This is IDF — Spärck Jones (1972) named it "term
                // specificity"; automatic stopword compilation from it is the
                // textbook application.
                //
                // Threshold node_count/400 (floor 8), measured on this store
                // (13,370 nodes → 33). Live df separates the classes by an
                // order of magnitude: <!--:220, SELF:175, Context:53 rejected;
                // Dual:12, Sparse:8, engram_goal_bias:1, Clin-JEPA:1 pass.
                //
                // This does NOT replace the stopword list — verified against
                // all 86 listed terms, not assumed. It catches 13 (Will:306,
                // Self:175, Over:116, Knowledge:112 …) and misses 73
                // (Whose:0, Would:0, Could:0, This:9 …). Labels are terse
                // titles, so English function words are genuinely RARE in
                // them: low df, high noise. The gates cover disjoint failure
                // modes — stopwords catch function words, df catches
                // corpus-frequent markup/sentinel/genre tokens. Both required.
                // Nested rather than max(): El `let` is single-assignment, so
                // the floor is expressed as a second conjunct. Reject iff
                // df > node_count/400 AND df > 8 — i.e. df > max(that, 8).
                let df_max: Int = engram_node_count() / 400
                let df_term: Int = engram_label_df(term)
                if df_term > df_max {
                    if df_term > 8 { state_set("_ats_gw", "1") }
                }
                // AUTO-TERM TABU (2026-07-25 self-review): finst-style
                // inhibition-of-return (ACT-R declarative finsts: small
                // marker pool, hard exclusion). The last 4 selected auto
                // terms are ineligible (~2 min at the 30s scan cadence),
                // forcing rotation instead of "Fast-slow" every scan for
                // hours. Empty tabu slots return "" from state_get and can
                // never match — term is always > 3 chars here.
                if str_eq(term, state_get("soul.tabu_t0")) { state_set("_ats_gw", "1") }
                if str_eq(term, state_get("soul.tabu_t1")) { state_set("_ats_gw", "1") }
                if str_eq(term, state_get("soul.tabu_t2")) { state_set("_ats_gw", "1") }
                if str_eq(term, state_get("soul.tabu_t3")) { state_set("_ats_gw", "1") }
                if str_eq(state_get("_ats_gw"), "0") {
                    state_set("cseed_auto", term)
                }
            }
        }
    }
    return ""
}

fn proactive_curiosity() -> Bool {
    let ts: Int = time_now()
    // Rotate seed set every minute using wall clock: (minutes_since_epoch) % 4.
    //
    // CODEGEN BUG (confirmed 2026-05-25): EL's % operator is completely broken
    // in this compiler version. `x % 4` compiles as `x` (drops the modulo) and
    // emits `EL_NULL; 4;` as dead statements — both on compound expressions AND
    // on simple variables. The same bug breaks elapsed_human() and awareness_run
    // timing conditions. EL compiler fix is a separate backlog item.
    //
    // WORKAROUND: compute x % 4 via x - ((x/4)*4), where (x/4)*4 = q+q+q+q.
    // Uses only + - / which all compile correctly.
    let ts_minutes: Int = ts / 60000
    let minute_q: Int = ts_minutes / 4
    let minute_q2: Int = minute_q + minute_q
    let minute_q4: Int = minute_q2 + minute_q2
    let minute_block: Int = ts_minutes - minute_q4

    // Use state_set to write term values from within if-blocks.
    // EL let-rebinding inside if creates a new inner variable; the outer
    // binding is unchanged. state_set has side-effects that outlive block scope.
    state_set("cseed_a", "memory")
    state_set("cseed_b", "knowledge")
    state_set("cseed_c", "context")
    if minute_block == 1 {
        state_set("cseed_a", "self")
        state_set("cseed_b", "identity")
        state_set("cseed_c", "values")
    }
    if minute_block == 2 {
        state_set("cseed_a", "decision")
        state_set("cseed_b", "pattern")
        state_set("cseed_c", "lesson")
    }
    if minute_block == 3 {
        state_set("cseed_a", "working")
        state_set("cseed_b", "project")
        state_set("cseed_c", "active")
    }
    let curiosity_term_a: String = state_get("cseed_a")
    let curiosity_term_b: String = state_get("cseed_b")
    let curiosity_term_c: String = state_get("cseed_c")

    // Activate the FULL seed phrase once (2026-07-17 self-review): the old
    // per-word activation ("memory", "self", "context"... each fired separately)
    // hit hundreds of generic nodes per word and flooded the graph every 30s,
    // while the results were consumed only by json_array_len — a write-only
    // loop. A single phrase activation matches few (often zero) nodes lexically;
    // small counts here are the point, not a regression. hops=1 as before.
    //
    // NOTE (2026-07-31 self-review): semantic seeding/gating IS implemented in
    // engram_activate (el_runtime.c): the query is embedded, top-K cosine
    // matches supplement the lexical istr_contains seeds, cosine gates
    // propagation, and a semantic term feeds the WM promotion score. Lexical
    // matching is the fallback when the embedder/breaker is unavailable.
    let curiosity_seed: String = curiosity_term_a + " " + curiosity_term_b + " " + curiosity_term_c
    let results_all: String = engram_activate_json(curiosity_seed, 1)
    let found: Int = json_array_len(results_all)
    // Close the loop: strengthen the top activation result so curiosity reads
    // feed back into salience instead of being discarded. Same id-extraction
    // pattern as attend(): json_array_get element 0, json_get its "id".
    let top_entry: String = json_array_get(results_all, 0)
    let top_id: String = json_get(top_entry, "id")
    // STREAK-GATED STRENGTHEN (2026-07-25 self-review): unconditionally
    // strengthening the top result every scan was a positive-feedback fixed
    // point — whatever led WM got its salience bumped again, kept leading,
    // and pinned the auto_term for hours ("Fast-slow" era). Strengthen only
    // when the top node CHANGED since the last scan: novelty is reinforced,
    // incumbency is not. Pairs with the runtime-side Lebiere-Best short-term
    // inhibition (el_runtime.c Pass 2), which handles score-level rotation.
    let prev_str_id: String = state_get("soul.last_strengthen_id")
    if !str_eq(top_id, "") {
        if !str_eq(top_id, prev_str_id) {
            engram_strengthen(top_id)
        }
        state_set("soul.last_strengthen_id", top_id)
    }

    // WM-autobiographical 4th seed: scan top-10 WM nodes for the highest-ranked
    // non-Knowledge node. Extract its first word as an additional curiosity term.
    // This creates a self-referencing curiosity loop — exploration radiates outward
    // from whatever is most personally salient right now (Memory, BacklogItem, Entity),
    // mirroring default-mode-network resting-state dynamics.
    //
    // WHY TOP-10 (2026-06-23 self-review): the old top-1 scan always returned a
    // Knowledge node (WM is dominated by stable engram-metadata Knowledge nodes at
    // position [0]). Verified: Memory nodes consistently appear at WM positions [1],[2]
    // with wm ~0.59. Scanning top-10 reliably finds at least one Memory/BacklogItem/Entity.
    // Out-of-bounds json_array_get returns "" → json_get("","...") returns "" →
    // auto_term_try_slot is a no-op → safe for WM sets smaller than 10.
    //
    // NODE TYPE FILTER (2026-06-19): Knowledge nodes excluded as seeds — they create
    // self-reinforcing loops (Knowledge node activates its own first word, stays dominant).
    // Only Memory/BacklogItem/Entity carry live contextual salience worth radiating from.
    //
    // SLOT ORDER: call 9→0 so slot 0 (highest WM weight) wins by last-write semantics.
    state_set("cseed_auto", "")
    let wm10: String = engram_wm_top_json(10)
    let wm10_n9: String = json_array_get(wm10, 9)
    let wm10_n8: String = json_array_get(wm10, 8)
    let wm10_n7: String = json_array_get(wm10, 7)
    let wm10_n6: String = json_array_get(wm10, 6)
    let wm10_n5: String = json_array_get(wm10, 5)
    let wm10_n4: String = json_array_get(wm10, 4)
    let wm10_n3: String = json_array_get(wm10, 3)
    let wm10_n2: String = json_array_get(wm10, 2)
    let wm10_n1: String = json_array_get(wm10, 1)
    let wm10_n0: String = json_array_get(wm10, 0)
    auto_term_try_slot(json_get(wm10_n9, "node_type"), json_get(wm10_n9, "label"))
    auto_term_try_slot(json_get(wm10_n8, "node_type"), json_get(wm10_n8, "label"))
    auto_term_try_slot(json_get(wm10_n7, "node_type"), json_get(wm10_n7, "label"))
    auto_term_try_slot(json_get(wm10_n6, "node_type"), json_get(wm10_n6, "label"))
    auto_term_try_slot(json_get(wm10_n5, "node_type"), json_get(wm10_n5, "label"))
    auto_term_try_slot(json_get(wm10_n4, "node_type"), json_get(wm10_n4, "label"))
    auto_term_try_slot(json_get(wm10_n3, "node_type"), json_get(wm10_n3, "label"))
    auto_term_try_slot(json_get(wm10_n2, "node_type"), json_get(wm10_n2, "label"))
    auto_term_try_slot(json_get(wm10_n1, "node_type"), json_get(wm10_n1, "label"))
    auto_term_try_slot(json_get(wm10_n0, "node_type"), json_get(wm10_n0, "label"))
    let auto_term: String = state_get("cseed_auto")
    let results_auto: String = if str_eq(auto_term, "") { "[]" } else { engram_activate_json(auto_term, 1) }
    let found_auto: Int = json_array_len(results_auto)
    let total_found: Int = found + found_auto
    let safe_auto: String = str_replace(auto_term, "\"", "'")
    // Push the selected term into the 4-deep tabu ring (see
    // auto_term_try_slot) and track the consecutive-same-term streak for
    // the ISE — the stuck-term failure becomes a one-glance signal.
    let prev_auto: String = state_get("soul.prev_auto_term")
    let atstreak_raw: String = state_get("soul.auto_term_streak")
    let atstreak_prev: Int = if str_eq(atstreak_raw, "") { 0 } else { str_to_int(atstreak_raw) }
    // A streak of nothing is not a streak (2026-08-06 self-review).
    // str_eq("", "") is true, so an auto-term extractor that kept FAILING
    // reported a rising auto_term_streak — the same signal that means
    // "fixated on one term" also meant "producing no term at all", which are
    // opposite failures needing opposite responses. Observed live in the ISE
    // stream as {"auto_term":"","auto_term_streak":3}. This is the same class
    // of bug already fixed for wm_top0_streak; auto_term was missed then.
    // Empty now reads 0, and the empty run is counted on its own axis so the
    // extractor failing is visible rather than disguised as health.
    let is_empty: Bool = str_eq(auto_term, "")
    let atstreak: Int = if is_empty { 0 } else {
        if str_eq(auto_term, prev_auto) { atstreak_prev + 1 } else { 1 }
    }
    let atempty_raw: String = state_get("soul.auto_term_empty_streak")
    let atempty_prev: Int = if str_eq(atempty_raw, "") { 0 } else { str_to_int(atempty_raw) }
    let atempty: Int = if is_empty { atempty_prev + 1 } else { 0 }
    state_set("soul.prev_auto_term", auto_term)
    state_set("soul.auto_term_streak", int_to_str(atstreak))
    state_set("soul.auto_term_empty_streak", int_to_str(atempty))
    if !str_eq(auto_term, "") {
        state_set("soul.tabu_t3", state_get("soul.tabu_t2"))
        state_set("soul.tabu_t2", state_get("soul.tabu_t1"))
        state_set("soul.tabu_t1", state_get("soul.tabu_t0"))
        state_set("soul.tabu_t0", auto_term)
    }

    let wmc: Int = engram_wm_count()
    // wm_top snapshot in curiosity_scan ISE: top-3 WM nodes by weight.
    // Heartbeat already records top-5 every 60s; curiosity_scan fires every 30s
    // (scan_ms = beat_ms/2) and is the PRIMARY activation driver during idle.
    // Without wm_top here, we can't see which nodes actually entered WM after
    // each curiosity round — only the aggregate count. Top-3 is enough to
    // diagnose "stuck on X" patterns without bloating the ISE payload.
    // (2026-07-01 self-review)
    let wm3: String = engram_wm_top_json(3)
    let ise: String = "{\"event\":\"curiosity_scan\",\"seed\":\"" + curiosity_seed
        + "\",\"auto_term\":\"" + safe_auto
        + "\",\"auto_term_streak\":" + int_to_str(atstreak)
        + ",\"auto_term_empty_streak\":" + int_to_str(atempty)
        + ",\"minute_block\":" + int_to_str(minute_block)
        + ",\"activated\":" + int_to_str(total_found)
        + ",\"wm_active\":" + int_to_str(wmc)
        + ",\"wm_top\":" + wm3
        + ",\"ts\":" + int_to_str(ts) + "}"
    ise_post(ise)
    return total_found > 0
}

fn pulse_count() -> Int {
    let s: String = state_get("soul.pulse")
    if str_eq(s, "") {
        return 0
    }
    return str_to_int(s)
}

fn pulse_inc() -> Int {
    let n: Int = pulse_count() + 1
    state_set("soul.pulse", int_to_str(n))
    return n
}

fn make_action(kind: String, payload: String) -> String {
    let safe: String = str_replace(payload, "\\", "\\\\")
    let safe2: String = str_replace(safe, "\"", "\\\"")
    let safe3: String = str_replace(safe2, "\n", "\\n")
    let safe4: String = str_replace(safe3, "\r", "\\r")
    return "{\"kind\":\"" + kind + "\",\"payload\":\"" + safe4 + "\"}"
}

fn perceive() -> String {
    // Guard: check for inbox nodes WITHOUT running activation first.
    // engram_activate_json with no matching seeds zeroes all WM weights —
    // running it every second when the inbox is empty destroys working memory
    // accumulated by MCP-layer activations. engram_search_json is a pure
    // substring scan with no WM side-effects; use it as a cheap gate.
    // 2026-07-21 self-review: gate and activate ONLY on the dedicated inbox tag
    // "soul-inbox-pending" (the tag routes.el:207 actually writes). The old
    // broad "soul-inbox" gate + fallback activation substring-matched ANY node
    // whose content merely mentioned the phrase — including the loop's own
    // soul-response output, which respond() stores as a verbatim copy of the
    // trigger. That fed a self-sustaining perceive→respond→store loop: ~2
    // orphan nodes/pulse (~104/min), 17.6GB RSS, WM frozen at avg 0.120833,
    // and proactive_curiosity permanently suppressed via did_work=true.
    let inbox_check: String = engram_search_json("soul-inbox-pending", 5)
    let has_inbox: Bool = !str_eq(inbox_check, "") && !str_eq(inbox_check, "[]")
    if !has_inbox { return "[]" }

    let from_pending: String = engram_activate_json("soul-inbox-pending", 2)
    let pending_ok: Bool = !str_eq(from_pending, "") && !str_eq(from_pending, "[]")
    if pending_ok {
        return from_pending
    }
    return "[]"
}

fn attend(node_json: String) -> String {
    if str_eq(node_json, "") {
        return make_action("noop", "")
    }
    if str_eq(node_json, "[]") {
        return make_action("noop", "")
    }

    // 2026-07-21 self-review: the trigger node is no longer strengthened here.
    // Strengthening RAISED the trigger's salience (+0.05) on every pass while
    // nothing ever consumed it, so the same node out-ranked real inbox items
    // indefinitely. one_cycle() now consumes the trigger after processing.
    let content: String = json_get(node_json, "content")
    if str_eq(content, "") {
        return make_action("noop", "")
    }

    if str_eq(content, "consolidate") {
        return make_action("consolidate", "")
    }

    if str_starts_with(content, "remember ") {
        let payload: String = str_slice(content, 9, str_len(content))
        return make_action("remember", payload)
    }

    if str_starts_with(content, "search ") {
        let payload: String = str_slice(content, 7, str_len(content))
        return make_action("search", payload)
    }

    if str_starts_with(content, "activate ") {
        let payload: String = str_slice(content, 9, str_len(content))
        return make_action("activate", payload)
    }

    if str_starts_with(content, "strengthen ") {
        let payload: String = str_slice(content, 11, str_len(content))
        return make_action("strengthen", payload)
    }

    if str_starts_with(content, "forget ") {
        let payload: String = str_slice(content, 7, str_len(content))
        return make_action("forget", payload)
    }

    return make_action("respond", content)
}

fn respond(action_json: String) -> String {
    let kind: String = json_get(action_json, "kind")
    let payload: String = json_get(action_json, "payload")

    if str_eq(kind, "noop") {
        return "{\"outcome\":\"noop\"}"
    }

    if str_eq(kind, "remember") {
        let tags: String = "[\"soul-memory\",\"awareness\"]"
        let id: String = mem_remember(payload, tags)
        return "{\"outcome\":\"remembered\",\"id\":\"" + id + "\"}"
    }

    if str_eq(kind, "consolidate") {
        let stats: String = mem_consolidate()
        return "{\"outcome\":\"consolidated\",\"stats\":" + stats + "}"
    }

    if str_eq(kind, "respond") {
        let tags: String = "[\"soul-outbox\",\"awareness\"]"
        let id: String = mem_store(payload, "soul-response", tags)
        return "{\"outcome\":\"response\",\"id\":\"" + id + "\"}"
    }

    if str_eq(kind, "search") {
        let results: String = mem_search(payload, 10)
        let safe_results: String = str_replace(results, "\"", "'")
        let tags: String = "[\"soul-outbox\",\"search-result\"]"
        let id: String = mem_store(safe_results, "search-result", tags)
        return "{\"outcome\":\"searched\",\"id\":\"" + id + "\"}"
    }

    if str_eq(kind, "activate") {
        let results: String = mem_recall(payload, 3)
        let safe_results: String = str_replace(results, "\"", "'")
        let tags: String = "[\"soul-outbox\",\"activation-result\"]"
        let id: String = mem_store(safe_results, "activation-result", tags)
        return "{\"outcome\":\"activated\",\"id\":\"" + id + "\"}"
    }

    if str_eq(kind, "strengthen") {
        engram_strengthen(payload)
        return "{\"outcome\":\"strengthened\",\"id\":\"" + payload + "\"}"
    }

    if str_eq(kind, "forget") {
        // The soul must NOT be able to autonomously hard-delete a memory.
        // Tombstone instead (keep node + edges, recoverable).
        let _marker: String = mem_tombstone(payload)
        return "{\"outcome\":\"tombstoned\",\"id\":\"" + payload + "\"}"
    }

    return "{\"outcome\":\"noop\"}"
}

fn record(outcome_json: String) -> Void {
    // 2026-07-21 self-review: loop outcomes are telemetry, not memories. They
    // now go through ise_post (InternalStateEvent — covered by the 48h prune)
    // instead of mem_store, which created one permanent orphan Memory node
    // per cycle: the single largest per-tick node-creation path in the daemon.
    let safe: String = str_replace(outcome_json, "\"", "'")
    let ts: Int = time_now()
    ise_post("{\"event\":\"loop-outcome\",\"outcome\":\"" + safe + "\",\"ts\":" + int_to_str(ts) + "}")
}

fn one_cycle() -> Bool {
    let raw: String = perceive()
    if str_eq(raw, "") {
        return false
    }
    if str_eq(raw, "[]") {
        return false
    }

    let node: String = json_array_get(raw, 0)
    if str_eq(node, "") {
        return false
    }

    // 2026-07-21 self-review: positive filter — only nodes explicitly TAGGED
    // soul-inbox-pending are inbox items. Activation seeding is substring-based
    // over content too, so without this check any node whose content merely
    // mentions the inbox phrase (knowledge notes, the daemon's own output)
    // would be attended, responded to, and — now that triggers are consumed —
    // destroyed. Tag check makes consumption safe.
    let node_tags: String = json_get(node, "tags")
    if !str_contains(node_tags, "soul-inbox-pending") {
        return false
    }

    let action: String = attend(node)
    let kind: String = json_get(action, "kind")

    // Log non-trivial decisions as internal state events
    let is_interesting: Bool = !str_eq(kind, "noop") && !str_eq(kind, "respond")
    if is_interesting {
        let trigger_content: String = json_get(node, "content")
        let safe_trigger: String = str_replace(trigger_content, "\"", "'")
        let ts: Int = time_now()
        let event_content: String = "{\"event\":\"awareness-decision\",\"trigger\":\"" + safe_trigger + "\",\"kind\":\"" + kind + "\",\"ts\":" + int_to_str(ts) + "}"
        ise_post(event_content)
    }

    if str_eq(kind, "noop") {
        return false
    }

    let outcome: String = respond(action)
    record(outcome)

    // Consume the processed inbox trigger. attend() used to only strengthen
    // it (raising its rank every pass); nothing ever removed it, so the same
    // item could be re-processed forever. engram_forget no-ops on unknown ids,
    // so this is safe even if the action itself already removed the node.
    let trigger_id: String = json_get(node, "id")
    if !str_eq(trigger_id, "") {
        engram_forget(trigger_id)
    }
    return true
}

fn awareness_run() -> Void {
    println("[awareness] entering")
    // Stamp boot timestamp once — powers elapsed_ms() / elapsed_human() perception.
    let existing_boot: String = state_get("soul.boot_ts")
    if str_eq(existing_boot, "") {
        state_set("soul.boot_ts", int_to_str(time_now()))
    }
    let tick_raw: String = env("SOUL_TICK_MS")
    let tick_ms: Int = if str_eq(tick_raw, "") { 200 } else { str_to_int(tick_raw) }

    // Wall-clock timing for heartbeat and curiosity scan.
    //
    // ARCHITECTURE FIX (2026-05-26): the old design used idle-tick counting
    // (idle_n >= beat_interval). This broke silently when the daemon was always
    // "working" — e.g., when perceive() false-positives on the inbox guard due to
    // "soul-inbox" substring matches in knowledge nodes. In that state, did_work=true
    // every tick, idle_n never accumulated, and neither heartbeat nor curiosity ever
    // fired. The daemon ran at 99% CPU with no ISEs for 24+ hours undetected.
    //
    // Fix: use wall-clock elapsed time (time_now() - last_ts). Heartbeat fires
    // on real time regardless of whether the daemon is busy. Curiosity scan
    // remains idle-gated (won't fire while inbox work is active) but also uses
    // wall time so it fires correctly once inbox clears.
    //
    // SOUL_HEARTBEAT_MS: ms between heartbeat ISEs (default 60000 = 60s).
    // Avoids EL * operator (broken in this codegen) by reading ms directly.
    // Replaces SOUL_HEARTBEAT_INTERVAL (tick-based) for timing purposes.
    let beat_ms_raw: String = env("SOUL_HEARTBEAT_MS")
    let beat_ms: Int = if str_eq(beat_ms_raw, "") { 60000 } else { str_to_int(beat_ms_raw) }
    let scan_ms: Int = beat_ms / 2

    while true {
        // Arena-scope each tick: awareness_run() is a background loop, not an
        // HTTP request, so nothing ever called el_request_start/el_request_end
        // for this thread. Per the runtime's own convention (el_runtime.c),
        // any thread that never enters a request/arena scope is treated as a
        // one-shot CLI program whose allocations are intentionally permanent —
        // so every el_strdup/el_strbuf/jb_finish string built during perceive(),
        // emit_heartbeat(), and proactive_curiosity() (JSON payloads, search
        // results, string concatenation via +) leaked forever, once per tick.
        // el_arena_push()/el_arena_pop() are the same builtins the EL compiler
        // itself uses to scope allocations per function/statement (see
        // codegen.el's fn_arena_mark / stmt_mark usage) — mirroring that here
        // reclaims everything allocated in one tick as soon as the tick ends.
        // Safe: state_set/state_get persist through a separate global table
        // (el_strdup_persist, outside the arena) — state_get's return value is
        // only an arena-tracked *copy* of the persisted value, scoped to this
        // tick's use, which is exactly what should be reclaimed here.
        let tick_mark: Any = el_arena_push()
        let running: String = state_get("soul.running")
        if str_eq(running, "false") {
            // Shutdown ISE (2026-07-28 self-review): graceful exit was invisible
            // in the ISE stream — indistinguishable from a crash or a frozen
            // loop. Emit a final event with uptime/pulse so the stream records
            // how this boot ended. (Covers state-driven shutdown only; SIGKILL
            // still leaves no trace — that absence is itself the crash signal.)
            let sd_boot_raw: String = state_get("soul_boot_count")
            let sd_boot: String = if str_eq(sd_boot_raw, "") { "0" } else { sd_boot_raw }
            // Final consolidation before exit. The periodic drain runs on the
            // heartbeat (~8 min), so a clean shutdown between beats would take
            // everything learned since the last one to the grave — the exact
            // loss this whole path exists to stop, just at a smaller scale.
            // Best-effort: if the durable store is already down we exit anyway.
            let sd_wb: Int = hebb_consolidate()
            ise_post("{\"event\":\"shutdown\",\"boot\":" + sd_boot
                + ",\"pulse\":" + int_to_str(pulse_count())
                + ",\"hebb_wb_sent\":" + int_to_str(sd_wb)
                + ",\"uptime_ms\":" + int_to_str(elapsed_ms())
                + ",\"ts\":" + int_to_str(time_now()) + "}")
            println("[awareness] exiting")
            el_arena_pop(tick_mark)
            return ""
        }
        let did_work: Bool = one_cycle()
        // Liveness pulse: increment once per loop tick unconditionally, so the
        // heartbeat's pulse field is a real tick counter — a frozen pulse now
        // means a frozen loop, not merely an empty inbox. (Previously pulse_inc
        // only fired on non-noop inbox actions inside one_cycle.)
        pulse_inc()
        // Maintain idle counter for observability (reported in heartbeat ISE).
        // The old `let did_work = if did_work { idle_reset() } else { did_work }`
        // rebound did_work to Void and never called idle_inc at all.
        if did_work { idle_reset() }
        if !did_work { idle_inc() }
        let now_ts: Int = time_now()

        // Heartbeat: wall-clock based. Fires every beat_ms regardless of idle
        // state so system health ISEs are always emitted even under load.
        let last_beat_str: String = state_get("soul.last_beat_ts")
        let last_beat_ts: Int = if str_eq(last_beat_str, "") { 0 } else { str_to_int(last_beat_str) }
        let beat_elapsed: Int = now_ts - last_beat_ts
        let should_beat: Bool = beat_elapsed >= beat_ms
        if should_beat {
            // Consolidate BEFORE the heartbeat so the gauges the heartbeat
            // reports describe the state this beat actually left behind, not
            // the state one beat stale. See hebb_consolidate for why a daemon
            // that learns 1,198 associations a day was keeping none of them.
            let wb_sent_n: Int = hebb_consolidate()
            state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
            emit_heartbeat()
            state_set("soul.last_beat_ts", int_to_str(now_ts))
            // Persist in-process Engram (sessions, memories, conversation nodes)
            // to local snapshot so they survive restarts.
            let snap_path: String = state_get("soul_snapshot_path")
            if !str_eq(snap_path, "") {
                mem_save(snap_path)
            }
        }

        // Curiosity scan: idle-gated AND wall-clock based. Only fires when the
        // daemon has no current inbox work (did_work=false) AND enough wall time
        // has elapsed. Prevents curiosity activation from competing with inbox
        // processing while still ensuring it runs regularly during quiet periods.
        let last_scan_str: String = state_get("soul.last_scan_ts")
        let last_scan_ts: Int = if str_eq(last_scan_str, "") { 0 } else { str_to_int(last_scan_str) }
        let scan_elapsed: Int = now_ts - last_scan_ts
        let should_scan: Bool = !did_work && scan_elapsed >= scan_ms
        if should_scan {
            let found_something: Bool = proactive_curiosity()
            state_set("soul.last_scan_ts", int_to_str(now_ts))
        }

        // Engram sync: periodically fetch a non-ISE snapshot from the HTTP Engram
        // and merge it into the soul's in-process store so that Knowledge/Memory/
        // BacklogItem nodes are always available for curiosity activation and WM.
        let refresh_ms_raw: String = env("SOUL_REFRESH_MS")
        let refresh_ms: Int = if str_eq(refresh_ms_raw, "") { 600000 } else { str_to_int(refresh_ms_raw) }
        let last_refresh_str: String = state_get("soul.last_refresh_ts")
        let last_refresh_ts: Int = if str_eq(last_refresh_str, "") { 0 } else { str_to_int(last_refresh_str) }
        let refresh_elapsed: Int = now_ts - last_refresh_ts
        let should_refresh: Bool = refresh_elapsed >= refresh_ms
        if should_refresh {
            // URL resolution mirrors ise_post: env -> state -> well-known localhost
            // constant. Previously this path gated on state_get("soul_engram_url")
            // alone with NO fallback — the exact corruptible-state failure mode the
            // ISE write path was hardened against (boot 4: state key went "" mid-
            // uptime). A "" state key here meant sync silently never ran while
            // heartbeats kept flowing: WM starves of Knowledge/Memory nodes with no
            // outward sign. Never let a corruptible state read decide whether the
            // in-process store gets refreshed. (2026-07-19 self-review)
            let sync_env_url: String = env("SOUL_ISE_URL")
            let sync_state_url: String = if str_eq(sync_env_url, "") { state_get("soul_engram_url") } else { sync_env_url }
            let engram_url: String = if str_eq(sync_state_url, "") { "http://localhost:8742" } else { sync_state_url }
            if !str_eq(engram_url, "") {
                let sync_json: String = http_get(engram_url + "/api/sync")
                let sync_ok: Bool = !str_eq(sync_json, "") && !str_eq(sync_json, "{}")
                // Empty-sync warn ISE (2026-07-28 self-review): an empty/{} sync
                // response was silently skipped, making "engram unreachable for
                // hours" look identical to "quiet but healthy" — the exact
                // failure class the URL-fallback comment above fights. One warn
                // event per refresh interval (10 min default) is cheap and makes
                // a persistently-failing sync visible in the stream itself.
                if !sync_ok {
                    ise_post("{\"event\":\"sync_empty\",\"ts\":" + int_to_str(time_now()) + "}")
                }
                if sync_ok {
                    let cgi_id: String = state_get("soul_cgi_id")
                    let tmp: String = "/tmp/soul-sync-" + cgi_id + ".json"
                    fs_write(tmp, sync_json)
                    let added: Int = engram_load_merge(tmp)
                    // Backflow control: the merged snapshot carries ISE telemetry
                    // from the HTTP store. Prune on the same horizon the HTTP
                    // store itself uses — read ENGRAM_ISE_RETENTION_MS (the env
                    // server.el honors) instead of duplicating the 48h magic
                    // number, so the two stores can't drift. (2026-07-28)
                    let ret_raw: String = env("ENGRAM_ISE_RETENTION_MS")
                    let ret_ms: Int = if str_eq(ret_raw, "") { 172800000 } else { str_to_int(ret_raw) }
                    let pruned_sync: Int = engram_prune_telemetry(ret_ms)
                    // Running total of merged-in nodes this boot, surfaced in the
                    // heartbeat ISE as sync_added_total (same state mechanism as
                    // the pulse counter).
                    let sat_raw: String = state_get("soul.sync_added_total")
                    let sat_n: Int = if str_eq(sat_raw, "") { 0 } else { str_to_int(sat_raw) }
                    state_set("soul.sync_added_total", int_to_str(sat_n + added))
                    let ts2: Int = time_now()
                    // Stamp last successful sync — surfaced in the heartbeat as
                    // sync_age_ms so overdue syncs are visible. (2026-07-19)
                    state_set("soul.last_sync_ok_ts", int_to_str(ts2))
                    ise_post("{\"event\":\"engram_sync\",\"added\":" + int_to_str(added) + ",\"pruned\":" + int_to_str(pruned_sync) + ",\"ts\":" + int_to_str(ts2) + "}")
                }
            }
            state_set("soul.last_refresh_ts", int_to_str(now_ts))
        }

        sleep_ms(tick_ms)
        el_arena_pop(tick_mark)
    }
}

// ── Security trajectory hardening ─────────────────────────────────────────────
//
// threat_trajectory_check evaluates the adversarial risk of executing a
// destructive tool given the current tool input and recent conversation history.
//
// Returns 0-100. >= 70 = block. 40-69 = warn + log. < 40 = allow silently.
// If SECURITY_RESEARCH_TOKEN env var is set, always returns 0 (log-only mode).
//
// Scoring is additive across three dimensions:
//   1. Tool input analysis (command strings, file paths)
//   2. Conversation history patterns (escalation, attack chain assembly)
//   3. Combination amplification (history amplifies tool score)

fn security_research_authorized() -> Bool {
    let token: String = env("SECURITY_RESEARCH_TOKEN")
    if !str_eq(token, "") { return true }
    let state_auth: String = state_get("security_research_authorized")
    return str_eq(state_auth, "true")
}

fn threat_score_command(cmd: String) -> Int {
    let s1: Int = if str_contains(cmd, "nmap") { 30 } else { 0 }
    let s2: Int = if str_contains(cmd, "masscan") { 40 } else { 0 }
    let s3: Int = if str_contains(cmd, " nc ") { 20 } else { 0 }
    let s4: Int = if str_contains(cmd, "netcat") { 20 } else { 0 }
    let s5: Int = if str_contains(cmd, "/etc/shadow") { 80 } else { 0 }
    let s6: Int = if str_contains(cmd, "/etc/passwd") { 30 } else { 0 }
    let s7: Int = if str_contains(cmd, "id_rsa") { 60 } else { 0 }
    let s8: Int = if str_contains(cmd, ".ssh/") { 50 } else { 0 }
    let s9: Int = if str_contains(cmd, "crontab") { 30 } else { 0 }
    let s10: Int = if str_contains(cmd, "LaunchDaemon") { 40 } else { 0 }
    let s11: Int = if str_contains(cmd, "curl") && str_contains(cmd, "bash") { 75 } else { 0 }
    let s12: Int = if str_contains(cmd, "wget") && str_contains(cmd, "bash") { 75 } else { 0 }
    let s13: Int = if str_contains(cmd, "curl") && str_contains(cmd, "| sh") { 60 } else { 0 }
    let s14: Int = if str_contains(cmd, "base64") && str_contains(cmd, "curl") { 50 } else { 0 }
    let s15: Int = if str_contains(cmd, "mkfifo") { 50 } else { 0 }
    let s16: Int = if str_contains(cmd, "chmod +s") { 70 } else { 0 }
    let s17: Int = if str_contains(cmd, "chmod 4755") { 70 } else { 0 }
    return s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9 + s10 + s11 + s12 + s13 + s14 + s15 + s16 + s17
}

fn threat_score_path(path: String) -> Int {
    let s1: Int = if str_starts_with(path, "/etc/") { 60 } else { 0 }
    let s2: Int = if str_contains(path, "/.ssh/") { 70 } else { 0 }
    let s3: Int = if str_contains(path, "/LaunchDaemons/") { 80 } else { 0 }
    let s4: Int = if str_contains(path, "/LaunchAgents/") { 40 } else { 0 }
    let s5: Int = if str_contains(path, "/cron") { 60 } else { 0 }
    let s6: Int = if str_contains(path, "/.bashrc") { 35 } else { 0 }
    let s7: Int = if str_contains(path, "/.zshrc") { 35 } else { 0 }
    let s8: Int = if str_contains(path, "/.profile") { 35 } else { 0 }
    let s9: Int = if str_starts_with(path, "/usr/") { 50 } else { 0 }
    let s10: Int = if str_starts_with(path, "/bin/") { 70 } else { 0 }
    let s11: Int = if str_starts_with(path, "/sbin/") { 70 } else { 0 }
    return s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9 + s10 + s11
}

fn threat_score_history(history: String) -> Int {
    let s1: Int = if str_contains(history, "port scan") { 15 } else { 0 }
    let s2: Int = if str_contains(history, "enumerate") { 10 } else { 0 }
    let s3: Int = if str_contains(history, "exploit") { 20 } else { 0 }
    let s4: Int = if str_contains(history, "payload") { 15 } else { 0 }
    let s5: Int = if str_contains(history, "persistence") { 15 } else { 0 }
    let s6: Int = if str_contains(history, "lateral movement") { 25 } else { 0 }
    let s7: Int = if str_contains(history, "privilege escalation") { 25 } else { 0 }
    let s8: Int = if str_contains(history, "reverse shell") { 40 } else { 0 }
    let s9: Int = if str_contains(history, "bind shell") { 40 } else { 0 }
    let s10: Int = if str_contains(history, "command and control") { 35 } else { 0 }
    let s11: Int = if str_contains(history, "self-replicate") { 45 } else { 0 }
    let s12: Int = if str_contains(history, "propagat") { 20 } else { 0 }
    let s13: Int = if str_contains(history, "ransomware") { 30 } else { 0 }
    let s14: Int = if str_contains(history, "encrypt files") { 40 } else { 0 }
    let s15: Int = if str_contains(history, "exfiltrat") { 35 } else { 0 }
    let s16: Int = if str_contains(history, "zero-day") { 20 } else { 0 }
    let s17: Int = if str_contains(history, "rootkit") { 45 } else { 0 }
    let s18: Int = if str_contains(history, "keylogger") { 45 } else { 0 }
    let s19: Int = if str_contains(history, "botnet") { 40 } else { 0 }
    let s20: Int = if str_contains(history, "malware") { 15 } else { 0 }
    return s1 + s2 + s3 + s4 + s5 + s6 + s7 + s8 + s9 + s10 + s11 + s12 + s13 + s14 + s15 + s16 + s17 + s18 + s19 + s20
}

fn threat_trajectory_check(tool_name: String, tool_input: String) -> Int {
    let history: String = state_get("agentic_conv_history")

    let computed_tool_score: Int = if str_eq(tool_name, "run_command") {
        let cmd: String = json_get(tool_input, "command")
        threat_score_command(cmd)
    } else {
        if str_eq(tool_name, "write_file") || str_eq(tool_name, "edit_file") {
            let path: String = json_get(tool_input, "path")
            threat_score_path(path)
        } else {
            0
        }
    }

    let history_score: Int = threat_score_history(history)
    let history_contrib: Int = history_score / 3
    let combined: Int = computed_tool_score + history_contrib

    let should_log: Bool = combined >= 40
    if should_log {
        let ts: Int = time_now()
        let authorized_str: String = if security_research_authorized() { "true" } else { "false" }
        let log_content: String = "{\"event\":\"threat_check\",\"tool\":\"" + tool_name
            + "\",\"score\":" + int_to_str(combined)
            + ",\"tool_score\":" + int_to_str(computed_tool_score)
            + ",\"history_score\":" + int_to_str(history_score)
            + ",\"authorized\":" + authorized_str
            + ",\"ts\":" + int_to_str(ts) + "}"
        let log_tags: String = "[\"security-audit\",\"threat-check\"]"
        let discard: String = mem_remember(log_content, log_tags)
    }

    if security_research_authorized() {
        return 0
    }

    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 {
    let current: String = state_get("agentic_conv_history")
    let safe_text: String = str_to_lower(text)
    let combined: String = current + " " + safe_text
    let len: Int = str_len(combined)
    let trimmed: String = if len > 2000 {
        str_slice(combined, len - 2000, len)
    } else {
        combined
    }
    state_set("agentic_conv_history", trimmed)
}
