64cd5055c5
el_runtime.c emitted 19 metric keys from engram_metrics_json; emit_heartbeat forwarded 14. Dropped on the floor: hebb_cands, hebb_cand_max, hebb_mass, hebb_edges, embed_consec_fail. The first two are exactly the pair the runtime added to answer the 08-06 question -- whether a stalled hebb_links means nothing co-activates or the threshold is too high. Undiagnosable from the durable record without them. An instrument computed but not plumbed to durable storage is not an instrument. Also adds the corpus damage STOCK, not just the flow. 08-08 fixed the JSON parser, watched txt_damaged (nodes damaged by a write THIS process) fall to 0, and recorded the defect closed. Census today: 2781 of 4100 nodes still damaged -- 67.8%, including the self root and every values node. A flow gauge reads 0 both when the corpus is clean and when it is uniformly damaged but quiescent. Sampled on a 30-beat countdown and carried with an explicit age.
1420 lines
84 KiB
EmacsLisp
1420 lines
84 KiB
EmacsLisp
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)
|
|
}
|