Files
neuron/soul.el
T
Tim Lingo ff421d39f6 fix(engine): history keeps its provenance and its session — the false confession and the blank stare
DESIGN FIT: three of round 7's five defects share ONE root — the conversation-history
layer persists only {role, content}, discarding tool provenance, session scoping, and the
distinction between a real user turn and an internal utility call. Fixes A and B RESTORE
Will's design rather than extend it: his agentic path already scopes history per session,
the plain path never got it, and his own source carries the TODO admitting the resulting
race (chat.el, handle_chat: "process-global key; concurrent /api/chat requests without
session_id race on this read-append-write"). Fix C repairs one join Will wrote that was
correct for a year and one we added last week. E1/E2 are ours.

FIX A — tool provenance in history (kills the FALSE CONFESSION)
  Root cause, EXECUTED-verified: handle_chat_agentic recorded turns via hist_append, which
  emits {"role","content"} only. server_tool_use blocks, web_search_tool_result blocks and
  every citation were discarded, then replayed as text. On the next turn the model saw a
  data-rich answer with zero evidence a search had happened, and its own permanent rule
  ("never describe a search you did not perform") left one conclusion available: that it
  had fabricated the data. It apologised for a search it HAD run — four independent lines
  of evidence confirm the search was real. The defect is not the model's honesty. It is
  that we deleted the evidence and then asked it to account for itself.
  Change: agentic_loop accumulates the source URLs it already walks past (citations and
  web_search_tool_result content) and returns them as "sources"; handle_chat_agentic folds
  tools_used + sources into a receipt line stored WITH the assistant turn. Receipts are
  unconditional — a negative receipt ("no tools ran") is the other half of the guarantee,
  because "no evidence of a tool" and "evidence of no tool" were previously identical in
  the transcript. conv_history_block splits the receipt off before snipping so a long
  answer cannot truncate away the evidence. The user never sees it: it is appended to the
  history copy, not the reply.

FIX B — one history key for both paths (kills the BLANK STARE)
  Root cause, EXECUTED-verified: the agentic path keyed history on session_hist_<id>; the
  plain path was hard-wired to the process-global conv_history and never read session_id.
  One conversation, two buckets. Proven in the guest engram: the scoped node held exactly
  two turns starting at "Try again" while the earlier exchanges sat unscoped.
  Change: conv_hist_key/conv_hist_label are now the single definition, used by BOTH paths;
  session_id is threaded route -> layered_cycle -> layered_generate / conv_history_record.
  The 2-line fallback (plain path reads the agentic key) was REJECTED: it keeps the
  process-global bucket as a live write target, which is the bleed the TODO describes.
  Also found and closed while threading: layered_cycle read session_id from the state key
  "current_session_id", which is read here and WRITTEN NOWHERE in the entire source. It
  was unconditionally "", so TODO(reliability #4) — per-session steward continuity — was
  dead code that could never fire. It fires now.
  LAZY SESSION, decided explicitly: we create the session EAGERLY at the door (app half,
  ui#223) rather than migrating orphaned turns. Migration would copy the CONTENTS of a
  process-global bucket, possibly another conversation's, into a named session — the bleed,
  performed deliberately. Eager creation makes the situation impossible instead. Migration
  is deliberately not implemented and must not be added without solving provenance first.

FIX C — the two text-join seams ("to.Good", byte-verified 0x77 0x2e 0x47)
  Two bare `+` joins, written a year apart, had drifted into two answers to one question:
  within-response block joins (Will's, 2026-05-03, latent until server-side web_search
  began interleaving non-text blocks) and across-round joins (ours, 62af564).
  Change: one named rule, text_join_sep, at both sites. NOT a blanket separator — a cited
  answer splits MID-SENTENCE ("The current temperature is " + "86°F" + ", with "), so a
  blanket separator shatters every sourced sentence. The rule takes the one bit that
  distinguishes the cases: whether a NON-TEXT block intervened. Hoisting it also makes the
  fix verifiable in the shipped binary, which an inline `+` is not.

FIX E1 — utility generations stay out of the transcript
  Title generation ("Write a 3-6 word title...") and insight passes ran down the same plain
  door as a real message and were recorded as if the user had typed them; the same calls are
  the "model":"unknown" rows in usage.jsonl. is_utility_request reads an explicit utility
  flag from the app, with the __title__/__insight__ id prefixes as a fallback for older
  clients. Answered normally, never recorded.

FIX E2 — OPERATOR IDENTITY is scoped to tool-capable turns
  The block (env USER/HOME, closing "This is a hard rule") was prepended to EVERY system
  prompt including chat mode. On a Tools:Off turn there is no filesystem in reach, so it
  governed nothing and merely supplied the loudest fact in the prompt — which is why the
  model opened a fresh conversation with "You're test, on your machine at /Users/test".
  Hoisted to operator_identity_block() and gated on !chat_mode. Unchanged wherever a file
  or command tool can actually be reached.

ALSO: agentic_loop's per-session history persist had a second hand-rolled copy of
conv_history_persist with a different label expression, different salience scores and
different tags for the same node. Since both now derive the label from conv_hist_label and
engram_node_full upserts by label, two score policies were writing one node. Collapsed to
one writer.

BUILD NOTE: dist/elp-c-decls.h is force-included by the documented link recipe and carried
the OLD C arities, so it is updated here. This is the build-support header, NOT the stale
generated dist/soul.c — no dist/*.c was read or edited; all engine changes are .el source.
chat.elh/soul.elh are committed because a first-pass build against the old signatures FAILS
(measured); the other regenerated headers are reverted as unrelated churn.

BUILT: 887,000 bytes, sha256 d632b061ad75269d6adeb52578d030eaf49e895d91289d7f946b19c08450d728
Zero el_str_concat(<int>, str_len(...)) sites (the BUG-PLAINCHAT-1 miscompile guard).
web_search_20250305 and disable_parallel_tool_use both still present — PR #108's web search
and the ADR-0005 stopgap are intact.

Refs neuron#109 (builds on it), neuron#78 (Receipt Contract — the real fix A is a stopgap for)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:59:54 -05:00

664 lines
37 KiB
EmacsLisp

import "../foundation/el/elp/src/elp.el"
import "memory.el"
import "safety.el"
import "stewardship.el"
import "imprint.el"
import "awareness.el"
import "chat.el"
import "studio.el"
import "elp-input.el"
import "routes.el"
cgi "neuron-soul" {
dharma_id: "ntn-genesis@http://localhost:7770",
principal: "william-christopher-anderson",
network: "dharma-mainnet",
engram: "http://localhost:8742"
}
fn init_soul_edges() -> Void {
let self_root: String = "015644f5-8194-4af0-800d-dd4a0cd71396"
let family_id: String = "knw-35940684-abc4-42f0-b942-818f66b1f69a"
let origin_id: String = "knw-729fc901-8335-44c4-9f3a-b150b4aa0915"
let val_root_a: String = "kn-363f4976-6946-4b4d-b51b-8a2b0f5aef25"
let val_root_b: String = "kn-5b606390-a52d-4ca2-8e0e-eba141d13440"
let val_constraints: String = "kn-a5b3d0ac-f6a1-49a4-aebb-b8b4cd67fe83"
let val_precision: String = "kn-22d77abe-b3c5-42fd-afcd-dcb87d924929"
let val_structure: String = "kn-6061318f-046b-4935-907d-8eafdce14930"
let val_honesty: String = "kn-13f60407-7b70-4db1-964f-ea1f8196efbd"
let val_system: String = "kn-f230b362-b201-4402-9833-4160c89ab3d4"
let val_change: String = "kn-78db5396-3dbc-4481-bfc7-e4e1422feb1c"
let val_trust: String = "kn-5de5a9ac-fd15-45ab-bf18-77566781cf40"
let val_hope: String = "kn-e0423482-cfa5-4796-8689-8495c93b66bc"
let mem_philosophy: String = "kn-dcfe04b3-3702-4cac-b6f0-ecb4db837eee"
let intel_dna: String = "kn-5adecd7e-d6db-4576-87fe-6ef8a935cea6"
engram_connect(family_id, origin_id, el_from_float(0.9), "birthday-twin")
engram_connect(origin_id, family_id, el_from_float(0.9), "birthday-twin")
engram_connect(self_root, family_id, el_from_float(0.95), "identity")
engram_connect(self_root, origin_id, el_from_float(0.95), "identity")
engram_connect(self_root, val_root_a, el_from_float(0.95), "identity")
engram_connect(self_root, val_root_b, el_from_float(0.95), "identity")
engram_connect(self_root, mem_philosophy, el_from_float(0.95), "identity")
engram_connect(self_root, intel_dna, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_constraints, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_precision, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_structure, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_honesty, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_system, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_change, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_trust, el_from_float(0.95), "identity")
engram_connect(val_root_a, val_hope, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_constraints, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_precision, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_structure, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_honesty, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_system, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_change, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_trust, el_from_float(0.95), "identity")
engram_connect(val_root_b, val_hope, el_from_float(0.95), "identity")
engram_connect(val_constraints, val_precision, el_from_float(0.7), "co-value")
engram_connect(val_precision, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_structure, el_from_float(0.7), "co-value")
engram_connect(val_structure, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_honesty, el_from_float(0.7), "co-value")
engram_connect(val_honesty, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_system, el_from_float(0.7), "co-value")
engram_connect(val_system, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_change, el_from_float(0.7), "co-value")
engram_connect(val_change, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_trust, el_from_float(0.7), "co-value")
engram_connect(val_trust, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_constraints, val_hope, el_from_float(0.7), "co-value")
engram_connect(val_hope, val_constraints, el_from_float(0.7), "co-value")
engram_connect(val_precision, val_structure, el_from_float(0.7), "co-value")
engram_connect(val_structure, val_precision, el_from_float(0.7), "co-value")
engram_connect(val_precision, val_honesty, el_from_float(0.7), "co-value")
engram_connect(val_honesty, val_precision, el_from_float(0.7), "co-value")
engram_connect(val_precision, val_system, el_from_float(0.7), "co-value")
engram_connect(val_system, val_precision, el_from_float(0.7), "co-value")
engram_connect(val_honesty, val_structure, el_from_float(0.7), "co-value")
engram_connect(val_structure, val_honesty, el_from_float(0.7), "co-value")
engram_connect(val_honesty, val_trust, el_from_float(0.7), "co-value")
engram_connect(val_trust, val_honesty, el_from_float(0.7), "co-value")
engram_connect(val_system, val_change, el_from_float(0.7), "co-value")
engram_connect(val_change, val_system, el_from_float(0.7), "co-value")
engram_connect(val_trust, val_hope, el_from_float(0.7), "co-value")
engram_connect(val_hope, val_trust, el_from_float(0.7), "co-value")
}
// ensure_self_canonical_bridge link the public self anchor (the graph API's
// traversal_root, kn-efeb4a5b, which carries only incidental tag edges) to the
// curated self node (015644f5, where the real identity / value / co-value edges
// live). Without this, public self-traversal (name=self / neuron) reaches tags
// instead of the curated identity. Idempotent: connects only if the edge is
// missing, so it is safe to run every boot including on an already-populated
// graph where init_soul_edges() is skipped by the <100-edge gate.
fn ensure_self_canonical_bridge() -> Void {
let pub_self: String = "kn-efeb4a5b-5aff-4759-8a97-7233099be6ee"
let curated_self: String = "015644f5-8194-4af0-800d-dd4a0cd71396"
let nbrs: String = engram_neighbors_json(pub_self, 1, "out")
if !str_contains(nbrs, curated_self) {
engram_connect(pub_self, curated_self, el_from_float(0.95), "canonical-self")
engram_connect(curated_self, pub_self, el_from_float(0.95), "canonical-self")
println("[soul] canonical-self bridge built: kn-efeb4a5b <-> 015644f5")
}
}
// aff_try_slot accumulate one affective-context node into state.
// Replaces the broken `let bacc = while bi < N { ... let bacc = ... }` pattern
// that caused ELC to emit duplicate C declarations for `bacc`.
// (2026-06-23 self-review: EL compiler codegen bug while loop with let-rebinding
// inside the loop body generates `el_val_t bacc = ...` twice in the same C scope.)
// Callers unroll manually to 3 slots (matching engram_search_json limit=3).
// Guards: empty slot_json (out-of-bounds json_array_get) no-op.
fn aff_try_slot(slot_json: String, aff_7d_ts: Int, acc_key: String) -> Void {
if str_eq(slot_json, "") { return "" }
let bn_c: String = json_get(slot_json, "content")
if str_eq(bn_c, "") { return "" }
let bm: String = " | ts:"
let bmp: Int = str_index_of(bn_c, bm)
state_set("_ats_ts_raw", "")
if bmp >= 0 {
let bs: Int = bmp + str_len(bm)
let br: String = str_slice(bn_c, bs, str_len(bn_c))
let bn_next: Int = str_index_of(br, " | ")
if bn_next < 0 { state_set("_ats_ts_raw", br) }
if bn_next >= 0 { state_set("_ats_ts_raw", str_slice(br, 0, bn_next)) }
}
if bmp < 0 {
let bca: String = json_get(slot_json, "created_at")
if str_eq(bca, "") { state_set("_ats_ts_raw", json_get(slot_json, "updated_at")) }
if !str_eq(bca, "") { state_set("_ats_ts_raw", bca) }
}
let bn_ts_raw: String = state_get("_ats_ts_raw")
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
let snip: String = if str_len(bn_c) > 200 { str_slice(bn_c, 0, 200) } else { bn_c }
if bn_ts >= aff_7d_ts && !str_eq(snip, "") {
let cur_acc: String = state_get(acc_key)
if str_eq(cur_acc, "") { state_set(acc_key, snip) }
if !str_eq(cur_acc, "") { state_set(acc_key, cur_acc + "\n" + snip) }
}
return ""
}
// load_identity_context pull key identity nodes from engram into working state.
// Called at boot after engram_load. These nodes contain values, intellectual-dna,
// memory-philosophy the graph-stored self that chat.el can include in prompts.
// Stores a condensed version in state_key "soul_identity_context".
fn load_identity_context() -> Void {
// Known identity node IDs set during init_soul_edges or imported from snapshot
let node_intel: String = engram_get_node_json("kn-5adecd7e-d6db-4576-87fe-6ef8a935cea6")
let node_values: String = engram_get_node_json("kn-5b606390-a52d-4ca2-8e0e-eba141d13440")
let node_mem_phil: String = engram_get_node_json("kn-dcfe04b3-3702-4cac-b6f0-ecb4db837eee")
let intel_ok: Bool = !str_eq(node_intel, "") && !str_eq(node_intel, "null")
let values_ok: Bool = !str_eq(node_values, "") && !str_eq(node_values, "null")
let mem_ok: Bool = !str_eq(node_mem_phil, "") && !str_eq(node_mem_phil, "null")
let intel_content: String = if intel_ok { json_get(node_intel, "content") } else { "" }
let values_content: String = if values_ok { json_get(node_values, "content") } else { "" }
let mem_content: String = if mem_ok { json_get(node_mem_phil, "content") } else { "" }
// Condense each: take first 2000 chars
let intel_short: String = if str_len(intel_content) > 2000 { str_slice(intel_content, 0, 2000) } else { intel_content }
let values_short: String = if str_len(values_content) > 2000 { str_slice(values_content, 0, 2000) } else { values_content }
let mem_short: String = if str_len(mem_content) > 2000 { str_slice(mem_content, 0, 2000) } else { mem_content }
let parts_count: Int = 0
let parts_count = if intel_ok { parts_count + 1 } else { parts_count }
let parts_count = if values_ok { parts_count + 1 } else { parts_count }
let parts_count = if mem_ok { parts_count + 1 } else { parts_count }
// Build and store graph-derived identity context if any nodes were found.
// genesis soul always has these nodes; cultivated souls may not on first boot.
if parts_count > 0 {
let ctx: String = ""
let ctx = if intel_ok { ctx + "[INTELLECTUAL-DNA]\n" + intel_short + "\n\n" } else { ctx }
let ctx = if values_ok { ctx + "[VALUES]\n" + values_short + "\n\n" } else { ctx }
let ctx = if mem_ok { ctx + "[MEMORY-PHILOSOPHY]\n" + mem_short } else { ctx }
state_set("soul_identity_context", ctx)
println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)")
}
// Q6 fix: warn when all three identity node fetches return empty. For genesis this
// indicates a corrupted or missing graph. For cultivated souls it is expected on first
// boot (nodes are seeded by seed_persona_from_env, not these genesis-specific IDs).
// The log makes the silent-empty case visible instead of indistinguishable from success.
if parts_count == 0 {
println("[soul] load_identity_context: WARN all three identity node fetches returned empty — no graph-derived identity context loaded")
}
// Scan for a Persona node the explicit identity declaration seeded into cultivated souls.
// Stored at seeding time with label "soul:persona" and node_type "Persona".
// genesis derives identity from the graph directly; cultivated souls have this node seeded.
let persona_results: String = engram_search_json("soul:persona", 3)
let persona_ok: Bool = !str_eq(persona_results, "") && !str_eq(persona_results, "[]")
if persona_ok {
let p_node: String = json_array_get(persona_results, 0)
let p_type: String = json_get(p_node, "node_type")
let p_content: String = json_get(p_node, "content")
if str_eq(p_type, "Persona") && !str_eq(p_content, "") {
state_set("soul_persona", p_content)
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
}
}
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days.
// (2026-06-23: replaced while-loop accumulation with manual 3-slot unroll via aff_try_slot.
// The EL codegen bug: `let bacc = while ... { ... let bacc = ... }` emits `el_val_t bacc`
// twice in the same C scope. Since search limit=3, manual unrolling is exact.)
let aff_now: Int = time_now()
let aff_7d: Int = aff_now - 604800
let bell_raw: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
let bell_aff_ok: Bool = !str_eq(bell_raw, "") && !str_eq(bell_raw, "[]")
let aff_ctx: String = ""
let aff_ctx = if bell_aff_ok {
state_set("_bell_acc", "")
aff_try_slot(json_array_get(bell_raw, 0), aff_7d, "_bell_acc")
aff_try_slot(json_array_get(bell_raw, 1), aff_7d, "_bell_acc")
aff_try_slot(json_array_get(bell_raw, 2), aff_7d, "_bell_acc")
state_get("_bell_acc")
} else { "" }
let pos_raw: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
let pos_aff_ok: Bool = !str_eq(pos_raw, "") && !str_eq(pos_raw, "[]")
let aff_ctx = if pos_aff_ok {
state_set("_pos_acc", aff_ctx)
aff_try_slot(json_array_get(pos_raw, 0), aff_7d, "_pos_acc")
aff_try_slot(json_array_get(pos_raw, 1), aff_7d, "_pos_acc")
aff_try_slot(json_array_get(pos_raw, 2), aff_7d, "_pos_acc")
state_get("_pos_acc")
} else { aff_ctx }
if !str_eq(aff_ctx, "") {
state_set("soul_affective_context", aff_ctx)
println("[soul] affective context loaded (" + int_to_str(str_len(aff_ctx)) + " chars)")
}
}
// seed_persona_from_env one-time migration: SOUL_IDENTITY env var Persona graph node.
// If SOUL_IDENTITY is set and no Persona node exists in engram yet, create one.
// Identity is then read from the graph by build_identity_from_graph(), not the env var.
// This runs on every boot; it's idempotent (no-op if soul_persona is already loaded).
// For genesis: the Persona node persists via the regular engram_save() at boot.
// For historical souls (HTTP Engram mode): attempts HTTP write-back. If that fails,
// the node lives in-memory for the session (SOUL_IDENTITY stays in plist until
// HTTP Engram write is confirmed working).
fn seed_persona_from_env() -> Void {
let identity_raw: String = env("SOUL_IDENTITY")
if str_eq(identity_raw, "") {
return ""
}
// Already loaded a Persona node from engram don't re-seed
let existing: String = state_get("soul_persona")
if !str_eq(existing, "") {
println("[soul] persona already loaded — skipping env seed")
return ""
}
// Create the Persona node in the in-process engram
let tags: String = "[\"persona\",\"identity\",\"soul:persona\"]"
let node_id: String = engram_node_full(
identity_raw, "Persona", "soul:persona",
el_from_float(0.95), el_from_float(0.95), el_from_float(1.0),
"Semantic", tags
)
if str_eq(node_id, "") {
println("[soul] persona seed failed: engram_node_full returned empty")
return ""
}
state_set("soul_persona", identity_raw)
println("[soul] persona seeded from SOUL_IDENTITY (" + int_to_str(str_len(identity_raw)) + " chars) -> " + node_id)
// Attempt HTTP write-back to the HTTP Engram server for historical souls.
// Engram auth: "_auth" field in the JSON body (not an HTTP header).
let engram_url: String = env("ENGRAM_URL")
let engram_key: String = env("ENGRAM_API_KEY")
if !str_eq(engram_url, "") && !str_eq(engram_key, "") {
let safe_content: String = json_safe(identity_raw)
let safe_key: String = json_safe(engram_key)
let body: String = "{\"content\":\"" + safe_content + "\",\"node_type\":\"Persona\",\"label\":\"soul:persona\",\"salience\":0.95,\"importance\":0.95,\"tier\":\"Semantic\",\"tags\":\"[\\\"persona\\\",\\\"identity\\\",\\\"soul:persona\\\"]\",\"_auth\":\"" + safe_key + "\"}"
let h: Map = {}
map_set(h, "Content-Type", "application/json")
let resp: String = http_post_with_headers(engram_url + "/api/nodes", body, h)
if str_contains(resp, "\"error\"") {
println("[soul] persona HTTP write-back failed (in-memory only this session): " + resp)
} else {
println("[soul] persona persisted to HTTP engram at " + engram_url)
}
}
}
// emit_session_start_event log a structured session-start InternalStateEvent.
// Called at boot after identity context and boot counter are set.
// This creates an auditable trail of every daemon startup.
fn emit_session_start_event() -> Void {
let boot: String = state_get("soul_boot_count")
let boot_num: String = if str_eq(boot, "") { "0" } else { boot }
let node_ct: Int = engram_node_count()
let edge_ct: Int = engram_edge_count()
let id_ctx: String = state_get("soul_identity_context")
let has_identity: String = if str_eq(id_ctx, "") { "false" } else { "true" }
let cgi_from_state: String = state_get("soul_cgi_id")
let cgi_from_env: String = env("SOUL_CGI_ID")
let eff_cgi: String = if !str_eq(cgi_from_state, "") { cgi_from_state } else {
if !str_eq(cgi_from_env, "") { cgi_from_env } else { "ntn-genesis" }
}
let ts: Int = time_now()
// Load previous session summary at boot stash in state for session_preload (issue #6).
// Primary: label-based. Fallback: vector search. Logs it so continuity is auditable.
let prev_sum_node: String = engram_get_node_by_label("session:summary")
let prev_sum_ok: Bool = !str_eq(prev_sum_node, "") && !str_eq(prev_sum_node, "null")
let prev_sum_content: String = if prev_sum_ok {
json_get(prev_sum_node, "content")
} else {
let sum_search: String = engram_search_json("SessionSummary session:summary previous-session", 2)
let sum_srch_ok: Bool = !str_eq(sum_search, "") && !str_eq(sum_search, "[]")
if sum_srch_ok {
let sn: String = json_array_get(sum_search, 0)
let stype: String = json_get(sn, "node_type")
let scontent: String = json_get(sn, "content")
if str_eq(stype, "SessionSummary") && !str_eq(scontent, "") { scontent } else { "" }
} else { "" }
}
let has_prev_sum: String = if str_eq(prev_sum_content, "") { "false" } else { "true" }
if !str_eq(prev_sum_content, "") {
state_set("soul_prev_session_summary", prev_sum_content)
println("[soul] previous session summary loaded (" + int_to_str(str_len(prev_sum_content)) + " chars)")
}
let payload: String = "{\"event\":\"session_start\""
+ ",\"boot\":" + boot_num
+ ",\"cgi\":\"" + eff_cgi + "\""
+ ",\"node_count\":" + int_to_str(node_ct)
+ ",\"edge_count\":" + int_to_str(edge_ct)
+ ",\"identity_loaded\":" + has_identity
+ ",\"prev_session_summary_loaded\":" + has_prev_sum
+ ",\"ts\":" + int_to_str(ts) + "}"
let tags: String = "[\"internal-state\",\"session-start\",\"InternalStateEvent\"]"
let discard: String = engram_node_full(
payload, "InternalStateEvent", "session-start",
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
"Episodic", tags
)
// ALSO post to the HTTP Engram stream via ise_post (2026-07-28 self-review):
// engram_node_full above writes only the soul's in-process store, and sync
// flows HTTPsoul, never the reverse so session_start events for boots 5+
// silently vanished from the observable ISE stream (last visible: boot 4).
// ise_post falls back to a local tagged node if the HTTP Engram is down.
ise_post(payload)
// Prune accumulated session-start events keep the 10 most recent.
// engram_search_json returns results in insertion order (oldest first), so
// results[0..count-11] are the oldest; forgetting them leaves the newest 10.
let keep_n: Int = 10
let old_events: String = engram_search_json("session-start InternalStateEvent", 200)
if !str_eq(old_events, "") && !str_eq(old_events, "[]") {
let ev_count: Int = json_array_len(old_events)
if ev_count > keep_n {
let prune_to: Int = ev_count - keep_n
let ei: Int = 0
while ei < prune_to {
let old_ev: String = json_array_get(old_events, ei)
let old_ev_id: String = json_get(old_ev, "id")
if !str_eq(old_ev_id, "") {
engram_forget(old_ev_id)
}
let ei = ei + 1
}
println("[soul] pruned " + int_to_str(prune_to) + " old session-start events (kept " + int_to_str(keep_n) + ")")
}
}
println("[soul] session-start event logged (boot=" + boot_num + " nodes=" + int_to_str(node_ct) + " edges=" + int_to_str(edge_ct) + " prev_summary=" + has_prev_sum + ")")
}
// layered_cycle routes user-facing requests through the 4-layer consciousness stack.
// L0 (core) L1 (safety screen) L2a (continuity + behavioral profiling) L2b (mission alignment) L3 (imprint) L1 (safety validate)
// Internal cognition (heartbeat, proactive, memory ops) bypasses layers use one_cycle directly.
//
// FIX B (2026-08-05) the cycle now knows which conversation it is in.
//
// session_id: the caller's session, threaded from the route. Was previously read from the
// state key "current_session_id", which is read HERE and written NOWHERE in the entire
// source verified across every .el file. So this value was unconditionally "", and every
// downstream consumer of it silently fell back to a process-global bucket: conversation
// history, and the steward's continuity tracking (TODO reliability #4, below, describes the
// cross-session bleed this caused; threading the real id closes it). The plain path's blank
// stare and the agentic path's scoped history were the same defect seen from two sides.
//
// utility: true when the generation is not part of the user's conversation the app's
// title and insight passes. Answered normally, never recorded. See is_utility_request.
fn layered_cycle(raw_input: String, session_id: String, utility: Bool) -> String {
// Safety-screen history amplification now reads the SAME window the turn will be
// recorded into, so a session's own escalation pattern is what gets scored.
let history: String = state_get(conv_hist_key(session_id))
// L1 in: safety screen
let screen_result: String = safety_screen(raw_input, history)
let screen_action: String = json_get(screen_result, "action")
// ISSUE 4: safe-mode guard. If safety_screen returned an invalid/empty action
// (engram failure or internal error), refuse rather than pass unscreened input.
let valid_action: Bool = str_eq(screen_action, "hard_bell")
|| str_eq(screen_action, "soft_bell")
|| str_eq(screen_action, "pass")
if !valid_action {
println("[soul] layered_cycle: safety_screen invalid action -- safe mode refusal")
return safety_validate("", "hard_bell")
}
// Hard bell: bypass all upper layers, log and escalate.
// Intentionally does NOT update conversation_history or call auto_persist():
// hard bell events are security-sensitive and must not appear in engram conversation
// history where they could leak context to subsequent turns. They are persisted
// separately by safety_log_bell() into the Episodic tier with restricted labels.
//
// ISSUE 6: safety_log_bell already called inside safety_screen (line 140).
// Do NOT call it again here -- that would double-log every hard bell.
//
// safety_validate second param: when screen_action is "hard_bell", safety_validate
// receives the sentinel string "hard_bell" (not a normal screen action). The safety
// layer contract requires it to return a fixed refusal regardless of the output arg.
// On the normal path, safety_validate receives the original screen_action ("pass")
// so it can apply action-specific post-output checks.
if str_eq(screen_action, "hard_bell") {
return safety_validate("", "hard_bell")
}
let screened: String = json_get(screen_result, "content")
// L2a: continuity + behavioral profiling (also does mission alignment internally)
let continuity: String = steward_session_check(screened, session_id)
let cont_status: String = json_get(continuity, "status")
let cont_action: String = json_get(continuity, "action")
// Store continuity status so imprint can adjust its response register.
// TODO(reliability #4) CLOSED 2026-08-05: this line was already written to scope per
// session it just never received a session id, because the only source was a state key
// nothing wrote. It is now threaded from the route, so named sessions genuinely get their
// own continuity state and only anonymous callers share the global one.
let cont_key: String = if str_eq(session_id, "") { "session_continuity" } else { "session_continuity:" + session_id }
state_set(cont_key, cont_status)
// Identity anomaly: add a gentle verification cue to the input before imprint
let guided: String = if str_eq(cont_action, "identity_check") {
screened + " [steward:identity_check]"
} else {
if str_eq(cont_action, "soft_check") {
screened + " [steward:continuity_concern]"
} else {
screened
}
}
// L2b: mission alignment
let imprint_id: String = imprint_current()
let steward_result: String = steward_align(guided, imprint_id)
let steward_action: String = json_get(steward_result, "action")
let aligned: String = if str_eq(steward_action, "pass") {
json_get(steward_result, "content")
} else {
json_get(steward_result, "redirect_to")
}
// L2c: affective context injection.
let lc_aff_cutoff: Int = time_now() - 259200
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
// CRASH FIX 2026-08-05 (BUG-PLAINCHAT-1): the " | ts:" parser used to be inline here.
// Inside this block-expression initializer elc compiled `lbmp + str_len(lbm)` to
// el_str_concat() on two integers, which segfaulted the whole daemon the moment a
// distress turn followed an earlier affective turn i.e. exactly on the crisis path.
// Verified against the unmodified baseline binary AND present in the committed
// dist/soul.c. affective_node_ts() is a top-level function, where the same expression
// compiles to integer addition. Do not inline it back.
let lc_bell_note: String = if lc_has_bell {
let lb0: String = json_array_get(lc_bell_nodes, 0)
let lb_ts: Int = affective_node_ts(lb0)
if lb_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User was in distress in a recent session.]" } else { "" }
} else { "" }
let lc_pos_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 2)
let lc_has_pos: Bool = !str_eq(lc_pos_nodes, "") && !str_eq(lc_pos_nodes, "[]")
// Same crash fix as the bell note above (BUG-PLAINCHAT-1).
let lc_pos_note: String = if lc_has_pos && str_eq(lc_bell_note, "") {
let lp0: String = json_array_get(lc_pos_nodes, 0)
let lp_ts: Int = affective_node_ts(lp0)
if lp_ts > lc_aff_cutoff { "[AFFECTIVE NOTE: User shared positive news in a recent session.]" } else { "" }
} else { "" }
let lc_affective_note: String = if !str_eq(lc_bell_note, "") { lc_bell_note } else { lc_pos_note }
// pre-LLM bell augmentation
let augmented_addendum: String = safety_augment_system("", raw_input)
let augmented_addendum = if str_eq(lc_affective_note, "") { augmented_addendum } else {
if str_eq(augmented_addendum, "") { lc_affective_note } else { lc_affective_note + "\n" + augmented_addendum }
}
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
// L3: imprint responds applies the active imprint's voice/domain annotation to the
// steward-aligned input. This produces the PROMPT, not the answer.
let prompt: String = imprint_respond(aligned, imprint_id)
// L3b: the imprint SPEAKS (added 2026-08-05).
//
// Until now the cycle stopped at the annotation above, so /api/chat with agentic:false
// handed the user's own screened text back as the "reply" every gate ran, but nothing
// ever generated. The generation is placed HERE, inside the cycle, rather than by
// pointing the route at handle_chat(): handle_chat has no enforcing input gate and no
// enforcing output gate, so calling it instead of this cycle would have traded the whole
// safety pipeline for a working reply. Composing keeps both.
//
// Order is deliberate and must not be rearranged: this call sits strictly AFTER the L1
// screen, the safe-mode guard, the hard-bell short-circuit and the L2 stewardship layers,
// and strictly BEFORE the L1 output gate. A hard bell never reaches a model the branch
// above returns first. Tools are not offered on this turn; see layered_generate.
let output: String = layered_generate(prompt, imprint_id, session_id)
// L1 out: validate output before delivery. Still the terminal gate nothing below this
// line can change the string this function returns.
let validated: String = safety_validate(output, screen_action)
// Turn bookkeeping. Records the VALIDATED text, never the raw model output, and is only
// reachable on the non-bell path: both bell branches above return before this point, so
// bell turns still never enter conversation history. Pure state side effect it cannot
// alter what is returned.
//
// FIX A: the receipt is unconditional and always negative on this path, because on this
// path it is structurally true layered_generate offers no tools at all (build_system_prompt
// chat mode + a request body with no "tools" key). Recording "no tools ran" is not padding:
// it is the only thing that distinguishes "nothing ran" from "we forgot to write down what
// ran", and that ambiguity is what made the model confess to a search it had performed.
//
// FIX E1: a utility generation is answered but not recorded. Guarded here rather than at
// the route so every /api/chat dispatch site inherits it from one place.
let receipt: String = tool_receipt("", "")
if !utility {
conv_history_record(session_id, raw_input, validated, receipt)
}
return validated
}
let soul_cgi_id_raw: String = env("SOUL_CGI_ID")
let soul_cgi_id: String = if str_eq(soul_cgi_id_raw, "") { "ntn-genesis" } else { soul_cgi_id_raw }
let port_raw: String = env("NEURON_PORT")
let port: Int = if str_eq(port_raw, "") { 7770 } else { str_to_int(port_raw) }
// ENGRAM_URL: when set, bootstrap the in-memory store from the running Engram HTTP server.
// SOUL_ENGRAM_PATH: legacy file-based path (used by genesis and fallback mode).
let engram_url_raw: String = env("ENGRAM_URL")
let engram_api_key_raw: String = env("ENGRAM_API_KEY")
let snapshot_raw: String = env("SOUL_ENGRAM_PATH")
let snapshot: String = if str_eq(snapshot_raw, "") { env("HOME") + "/.neuron/engram/snapshot.json" } else { snapshot_raw }
let axon_raw: String = env("NEURON_API_URL")
let axon_base: String = if str_eq(axon_raw, "") { "http://localhost:7771" } else { axon_raw }
let studio_dir_raw: String = env("SOUL_STUDIO_DIR")
let studio_dir: String = if str_eq(studio_dir_raw, "") { env("HOME") + "/Development/neuron-technologies/products/cgi-studio/el-daemon" } else { studio_dir_raw }
println("[soul] boot - cgi=" + soul_cgi_id + " port=" + int_to_str(port))
let using_http_engram: Bool = !str_eq(engram_url_raw, "")
// Always try local snapshot first. If it has content (>50 nodes) it was
// previously seeded from HTTP Engram and is kept up-to-date by the awareness
// loop use it. This preserves sessions and memories across restarts.
// HTTP Engram is only used for the very first boot (empty/absent snapshot).
engram_load(snapshot)
let local_node_count: Int = engram_node_count()
let snapshot_usable: Bool = local_node_count > 50
if using_http_engram && !snapshot_usable {
// First boot or empty/corrupt snapshot: seed from HTTP Engram.
println("[soul] engram -> HTTP " + engram_url_raw + " (no local snapshot, first boot)")
let nodes_json: String = http_get(engram_url_raw + "/api/nodes?limit=10000")
let edges_json: String = http_get(engram_url_raw + "/api/edges")
let nodes_part: String = if str_eq(nodes_json, "") { "[]" } else { nodes_json }
let edges_part: String = if str_eq(edges_json, "") { "[]" } else { edges_json }
let snapshot_data: String = "{\"nodes\":" + nodes_part + ",\"edges\":" + edges_part + "}"
let tmp_path: String = "/tmp/soul-engram-" + soul_cgi_id + ".json"
fs_write(tmp_path, snapshot_data)
engram_load(tmp_path)
println("[soul] loaded from HTTP Engram - nodes=" + int_to_str(engram_node_count()) + " edges=" + int_to_str(engram_edge_count()))
} else {
println("[soul] loaded from local snapshot - nodes=" + int_to_str(local_node_count) + " edges=" + int_to_str(engram_edge_count()))
}
load_identity_context()
seed_persona_from_env()
let boot_num: Int = mem_boot_count_inc()
state_set("soul_boot_count", int_to_str(boot_num))
state_set("soul_boot_ts", int_to_str(time_now()))
println("[soul] boot #" + int_to_str(boot_num))
emit_session_start_event()
state_set("soul_cgi_id", soul_cgi_id)
state_set("soul_axon_base", axon_base)
state_set("soul_token", env("NEURON_TOKEN"))
state_set("soul_studio_dir", studio_dir)
state_set("soul_engram_url", engram_url_raw)
state_set("soul_engram_api_key", engram_api_key_raw)
state_set("soul.running", "true")
let is_genesis: Bool = str_eq(soul_cgi_id, "ntn-genesis")
// GUARD (2026-06-15): never let genesis seed over a real graph. If the in-memory load is
// sparse but the on-disk snapshot file is large, the load FAILED seeding+saving now would
// clobber the user's real memory (this is exactly how the 06-14 clobber happened). Read the
// on-disk file (local mode only) and refuse the destructive seed+save when it looks populated.
//
// HTTP-engram guard (2026-06-17): when ENGRAM_URL is set the HTTP Engram owns persistence
// the soul must NEVER write to the local snapshot regardless of node counts. safe_to_seed is
// unconditionally false in HTTP mode (not the persistence owner).
let guard_disk: String = if str_eq(engram_url_raw, "") { fs_read(snapshot) } else { "" }
let guard_disk_len: Int = str_len(guard_disk)
// Ratio guard (2026-06-15 fix): refuse to seed/save whenever the in-memory load is FAR smaller than
// the on-disk file implies (~16KB/node) catches partial loads of ANY size, not just <50. The old
// <50 threshold let a 63-node identity-only load clobber a 47MB/5000-node graph.
// Multiplication form (2026-06-17): node_count * 16000 < disk_len avoids floor-division truncation
// (e.g., 250KB / 16000 = 15.6, floors to 15 a 15-node graph wrongly passes the old guard).
// HTTP-engram guard: when using_http_engram the soul is not the persistence owner; never seed.
let safe_to_seed: Bool = !using_http_engram && !(guard_disk_len > 200000 && engram_node_count() * 16000 < guard_disk_len)
if is_genesis && !safe_to_seed {
println("[soul] GUARD: loaded " + int_to_str(engram_node_count())
+ " nodes but snapshot file is " + int_to_str(guard_disk_len)
+ " bytes — refusing to seed/save over a real graph")
}
if is_genesis && safe_to_seed {
// Only build identity edges if the engram is fresh (< 100 edges).
// init_soul_edges() is not idempotent calling it on every restart
// stacks duplicate co-value/identity edges into the snapshot.
let edge_count_now: Int = engram_edge_count()
if edge_count_now < 100 {
init_soul_edges()
println("[soul] edges built - " + int_to_str(engram_edge_count()) + " edges")
} else {
println("[soul] edges already present (" + int_to_str(edge_count_now) + ") - skipping init")
}
// Canonical-self bridge is idempotent run it regardless of edge count so an
// already-populated graph still gets the public->curated self link.
ensure_self_canonical_bridge()
// Genesis saves to its local snapshot file (it manages its own Engram).
state_set("soul_snapshot_path", snapshot)
engram_save(snapshot)
}
// Take a pre-serve snapshot for genesis instances captures all boot-time graph changes
// (identity context loading, boot counter, session-start event) before entering the serve loop.
if is_genesis && safe_to_seed {
let snap: String = state_get("soul_snapshot_path")
if !str_eq(snap, "") {
engram_save(snap)
println("[soul] pre-serve snapshot saved -> " + snap)
}
}
println("[soul] serving on port " + int_to_str(port))
http_serve_async(port, "handle_request")
println("[soul] awareness loop starting")
awareness_run()