Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aef687b57c | |||
| 6edf9937dd | |||
| e447a87a00 | |||
| 575ff1329a | |||
| db33b0cb91 | |||
| f35569d4bb | |||
| 94b71b6e6b | |||
| 392d2416ec | |||
| e6da638536 | |||
| 2865d6ad26 | |||
| 47d0e6f985 | |||
| d008649c3e | |||
| aa70c5dde6 | |||
| deddb9a18e | |||
| 494d973a3b | |||
| 34551695a1 | |||
| 615f0cee08 |
@@ -678,6 +678,8 @@ fn threat_trajectory_check(tool_name: String, tool_input: String) -> Int {
|
||||
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)
|
||||
|
||||
@@ -12,113 +12,47 @@ fn chat_default_model() -> String {
|
||||
return "claude-sonnet-4-5"
|
||||
}
|
||||
|
||||
// parse_salience_100 — convert a %g-serialized float to integer * 100.
|
||||
// The C runtime serializes floats with %g which trims trailing zeros:
|
||||
// 0.70 → "0.7", 0.60 → "0.6", 0.50 → "0.5", 1.0 → "1"
|
||||
// The naive str_replace(".", "") approach breaks for single-decimal strings:
|
||||
// "0.7" → "07" → str_to_int → 7 (WRONG, should be 70)
|
||||
// "0.5" → "05" → str_to_int → 5 (WRONG, should be 50)
|
||||
// "0.85" → "085" → str_to_int → 85 (accidentally correct — two decimal digits)
|
||||
// Fix: use str_index_of to find the decimal point and scale accordingly:
|
||||
// No decimal ("1"): multiply raw by 100
|
||||
// One decimal digit ("0.7"): multiply stripped value by 10
|
||||
// Two+ decimal digits ("0.85"): stripped value is already in hundredths
|
||||
fn parse_salience_100(s: String) -> Int {
|
||||
if str_eq(s, "") { return 70 }
|
||||
let dot_pos: Int = str_index_of(s, ".")
|
||||
let raw: Int = if dot_pos < 0 {
|
||||
// No decimal point — integer like "1" means 100%
|
||||
str_to_int(s) * 100
|
||||
} else {
|
||||
let after_dot: String = str_slice(s, dot_pos + 1, str_len(s))
|
||||
let decimal_digits: Int = str_len(after_dot)
|
||||
let stripped: Int = str_to_int(str_replace(s, ".", ""))
|
||||
if decimal_digits == 1 { stripped * 10 } else { stripped }
|
||||
}
|
||||
if raw > 100 { 100 } else { if raw < 0 { 0 } else { raw } }
|
||||
}
|
||||
|
||||
// engram_score_node — compute a recency x relevance score for a single engram
|
||||
// node JSON object. Higher is better.
|
||||
//
|
||||
// Bugs fixed vs original implementation:
|
||||
// 1. FLOAT PARSING: parse_salience_100 correctly handles %g single-decimal output.
|
||||
// "0.7" → 70, "0.6" → 60, "0.5" → 50 (was: 7, 6, 5 — scored near zero and
|
||||
// were filtered by threshold=25, making the function broken for the majority
|
||||
// of the graph where conv/utterance nodes have salience/importance ≈ 0.6/0.7).
|
||||
// 2. RECENCY USES LAST TOUCH: uses max(created_at, updated_at, last_activated) so
|
||||
// nodes strengthened by engram_strengthen() after chat turns are not penalised
|
||||
// for a stale created_at. A node referenced yesterday but created 25 days ago
|
||||
// now correctly scores as fresh rather than borderline-filtered.
|
||||
// 3. COMPRESSED RECENCY RANGE: old formula (sal * imp * recency / 10000) gave
|
||||
// recency a 10x dynamic range (10-100) vs 1.9x for salience/importance. A
|
||||
// canonical high-importance node at 30 days scored the same as a fresh noise
|
||||
// node. New formula compresses recency to 1.54x via (50 + recency/2) weight.
|
||||
// 4. SOFTER FLOOR: recency floor raised from 10 to 30 with tier-aware decay windows
|
||||
// so canonical identity/persona nodes never bottom out to near-zero.
|
||||
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
|
||||
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
|
||||
// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5.
|
||||
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
|
||||
// nodes to the bottom so they get trimmed when we cap context size.
|
||||
fn engram_score_node(node_json: String) -> Int {
|
||||
let salience_str: String = json_get(node_json, "salience")
|
||||
let importance_str: String = json_get(node_json, "importance")
|
||||
let created_str: String = json_get(node_json, "created_at")
|
||||
let updated_str: String = json_get(node_json, "updated_at")
|
||||
let activated_str: String = json_get(node_json, "last_activated")
|
||||
let tier_str: String = json_get(node_json, "tier")
|
||||
|
||||
// parse_salience_100 handles "0.7" → 70, "0.85" → 85, "1.0" → 100, "1" → 100
|
||||
let salience_100: Int = parse_salience_100(salience_str)
|
||||
let importance_100: Int = parse_salience_100(importance_str)
|
||||
|
||||
// Recency: use max(created_at, updated_at, last_activated).
|
||||
// last_activated is updated by engram_strengthen() every chat turn — nodes
|
||||
// actively referenced score fresh regardless of original write time.
|
||||
let now_ts: Int = time_now()
|
||||
let created_ts: Int = if str_eq(created_str, "") { 0 } else { str_to_int(created_str) }
|
||||
let updated_ts: Int = if str_eq(updated_str, "") { 0 } else { str_to_int(updated_str) }
|
||||
let activated_ts: Int = if str_eq(activated_str, "") { 0 } else { str_to_int(activated_str) }
|
||||
let best_ts_ab: Int = if updated_ts > created_ts { updated_ts } else { created_ts }
|
||||
let best_ts: Int = if activated_ts > best_ts_ab { activated_ts } else { best_ts_ab }
|
||||
let recency_100: Int = if best_ts == 0 { 50 } else {
|
||||
let age_secs: Int = now_ts - best_ts
|
||||
// Guard against clock skew (future timestamps): treat as brand new.
|
||||
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
|
||||
// Tier-aware decay, softer floor (30 not 10):
|
||||
// Canonical: 365-day window — foundational identity/persona nodes.
|
||||
// Episodic: 90-day window — conversation context fades moderately.
|
||||
// Working/untiered: 35-day window — transient task state.
|
||||
let is_canonical: Bool = str_eq(tier_str, "Canonical")
|
||||
let is_episodic: Bool = str_eq(tier_str, "Episodic")
|
||||
let decay: Int = if is_canonical {
|
||||
let drop: Int = if age_days >= 365 { 70 } else { age_days * 70 / 365 }
|
||||
100 - drop
|
||||
} else {
|
||||
if is_episodic {
|
||||
if age_days >= 90 { 30 } else { 100 - (age_days * 70 / 90) }
|
||||
} else {
|
||||
if age_days >= 35 { 30 } else { 100 - (age_days * 2) }
|
||||
}
|
||||
}
|
||||
if decay < 30 { 30 } else { decay }
|
||||
// Parse as floats via * 100 integer arithmetic (el has no float math)
|
||||
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
|
||||
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
|
||||
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
|
||||
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
||||
}
|
||||
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
|
||||
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
||||
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
||||
}
|
||||
|
||||
// Compressed recency weight (50 + recency/2): range 65-100 (1.54x dynamic range).
|
||||
// Old formula had 10x recency range which drowned out relevance for old-but-important
|
||||
// nodes. New: relevance (0-100) × recency_weight (65-100) / 100 → score 0-100.
|
||||
// salience_100 and importance_100 are already in the 0-100 range (parse_salience_100
|
||||
// returns e.g. 70 for "0.7"). Dividing by 100 keeps relevance in 0-100.
|
||||
// Dividing by 10000 caused integer truncation to 0 for all real-world nodes
|
||||
// (e.g., sal=0.7, imp=0.7 → 70*70/10000 = 0 instead of 49).
|
||||
let relevance: Int = salience_100 * importance_100 / 100
|
||||
let recency_weight: Int = 50 + recency_100 / 2
|
||||
return relevance * recency_weight / 100
|
||||
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
||||
let now_ts: Int = time_now()
|
||||
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
|
||||
let created_ts: Int = str_to_int(created_str)
|
||||
let age_secs: Int = now_ts - created_ts
|
||||
let age_days: Int = age_secs / 86400
|
||||
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||
if decay < 10 { 10 } else { decay }
|
||||
}
|
||||
|
||||
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
|
||||
return salience_100 * importance_100 * recency_100 / 10000
|
||||
}
|
||||
|
||||
// engram_compile_ranked — build a context string from a JSON array of node objects,
|
||||
// ordered best-first by score. Only nodes above threshold=10 are included.
|
||||
// With corrected formula (sal*imp/100): sal=0.5*imp=0.5 at max recency scores 25;
|
||||
// sal=0.5*imp=0.5 at Working floor (recency=30, weight=65) scores 16.
|
||||
// Threshold=10 gives safe headroom for low-salience nodes near the recency floor,
|
||||
// while still filtering near-zero noise (e.g., sal=0.1*imp=0.1 → score≤1).
|
||||
// Returns at most max_nodes entries. max_nodes must not exceed 20 (sentinel limit).
|
||||
// ordered best-first by score. Only nodes above a minimum score (25 = salience 0.5 *
|
||||
// importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most
|
||||
// max_nodes entries concatenated as JSON array text. Because el has no sort primitive,
|
||||
// we do a single selection pass picking the top N by linear scan (N=10 cap).
|
||||
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||
if str_eq(nodes_json, "") { return "" }
|
||||
if str_eq(nodes_json, "[]") { return "" }
|
||||
@@ -139,10 +73,8 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||
while ci < total {
|
||||
let node: String = json_array_get(nodes_json, ci)
|
||||
let score: Int = engram_score_node(node)
|
||||
// Threshold=10: allows moderately-relevant older nodes while filtering noise.
|
||||
// Example: sal=0.5 imp=0.5 at Working recency floor (35+ days) → score 16,
|
||||
// which passes. A near-zero node (sal=0.1 imp=0.1) → score ≤ 1, filtered.
|
||||
let above_thresh: Bool = score >= 10
|
||||
// Only include reasonably relevant nodes (threshold=25)
|
||||
let above_thresh: Bool = score >= 25
|
||||
// Check this index wasn't already selected (sentinel: look for idx marker)
|
||||
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
|
||||
let already_picked: Bool = str_contains(selected, idx_marker)
|
||||
@@ -169,7 +101,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||
// Strip the _sel_N sentinel fields that were used for duplicate-detection bookkeeping.
|
||||
// The sentinels have the form "\"_sel_N\":1," (trailing comma, space before next key).
|
||||
// We injected them as the first field in each object, so the pattern is predictable.
|
||||
// Because el has no regex, remove up to 20 possible sentinel variants by literal replace.
|
||||
// Because el has no regex, remove up to 10 possible sentinel variants by literal replace.
|
||||
let clean: String = "[" + selected + "]"
|
||||
let c0: String = str_replace(clean, "\"_sel_0\":1,", "")
|
||||
let c1: String = str_replace(c0, "\"_sel_1\":1,", "")
|
||||
@@ -181,17 +113,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||
let c7: String = str_replace(c6, "\"_sel_7\":1,", "")
|
||||
let c8: String = str_replace(c7, "\"_sel_8\":1,", "")
|
||||
let c9: String = str_replace(c8, "\"_sel_9\":1,", "")
|
||||
let c10: String = str_replace(c9, "\"_sel_10\":1,", "")
|
||||
let c11: String = str_replace(c10, "\"_sel_11\":1,", "")
|
||||
let c12: String = str_replace(c11, "\"_sel_12\":1,", "")
|
||||
let c13: String = str_replace(c12, "\"_sel_13\":1,", "")
|
||||
let c14: String = str_replace(c13, "\"_sel_14\":1,", "")
|
||||
let c15: String = str_replace(c14, "\"_sel_15\":1,", "")
|
||||
let c16: String = str_replace(c15, "\"_sel_16\":1,", "")
|
||||
let c17: String = str_replace(c16, "\"_sel_17\":1,", "")
|
||||
let c18: String = str_replace(c17, "\"_sel_18\":1,", "")
|
||||
let c19: String = str_replace(c18, "\"_sel_19\":1,", "")
|
||||
return c19
|
||||
return c9
|
||||
}
|
||||
|
||||
fn engram_compile(intent: String) -> String {
|
||||
@@ -202,11 +124,8 @@ fn engram_compile(intent: String) -> String {
|
||||
let act_ok: Bool = !str_eq(activate_json, "") && !str_eq(activate_json, "[]")
|
||||
let srch_ok: Bool = !str_eq(search_json, "") && !str_eq(search_json, "[]")
|
||||
|
||||
// Activation nodes (spreading activation) are high-signal but apply scoring via
|
||||
// engram_compile_ranked with threshold=5 to exclude genuinely zero-quality stale
|
||||
// nodes that happen to be graph-connected. The threshold of 5 is well below the
|
||||
// search path threshold of 15 to preserve the activation path's higher recall.
|
||||
let act_part: String = if act_ok { engram_compile_ranked(activate_json, 5) } else { "" }
|
||||
// Activation nodes (spreading activation) are already high-signal — keep all 5.
|
||||
let act_part: String = if act_ok { activate_json } else { "" }
|
||||
|
||||
// Rank search results and keep only the top 8 (was: flat 15 unranked).
|
||||
// This cuts context noise roughly in half while preserving the best-scoring nodes.
|
||||
@@ -461,6 +380,8 @@ fn handle_chat(body: String) -> String {
|
||||
}
|
||||
|
||||
// Load history BEFORE compiling context so we can anchor activation to the thread.
|
||||
// TODO(reliability #3 — conv_history global race): process-global key; concurrent
|
||||
// /api/chat requests without session_id race on this read-append-write.
|
||||
let state_hist: String = state_get("conv_history")
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
@@ -1019,15 +940,18 @@ fn is_builtin_tool(tool_name: String) -> Bool {
|
||||
|| str_starts_with(tool_name, "neuron_")
|
||||
}
|
||||
|
||||
// next_bridge_id — monotonic correlation id for a suspended agentic turn.
|
||||
// Combines boot-relative time with a per-process counter so two unknown-tool
|
||||
// suspensions in the same second still get distinct ids.
|
||||
// next_bridge_id — unique correlation id for a suspended agentic turn.
|
||||
// Uses uuid_v4() as the primary uniqueness guarantee — concurrent calls cannot collide.
|
||||
//
|
||||
// TODO(reliability #6): mcp_bridge_seq RMW is non-atomic. Now benign because
|
||||
// uuid_v4() provides collision-free uniqueness. Counter is kept for readability only.
|
||||
fn next_bridge_id() -> String {
|
||||
let prev: String = state_get("mcp_bridge_seq")
|
||||
let n: Int = if str_eq(prev, "") { 0 } else { str_to_int(prev) }
|
||||
let next: Int = n + 1
|
||||
state_set("mcp_bridge_seq", int_to_str(next))
|
||||
return "br-" + int_to_str(time_now()) + "-" + int_to_str(next)
|
||||
let uid: String = uuid_v4()
|
||||
return "br-" + uid
|
||||
}
|
||||
|
||||
fn handle_chat_agentic(body: String) -> String {
|
||||
@@ -1055,7 +979,7 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
if str_eq(screen_action, "hard_bell") {
|
||||
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
|
||||
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
|
||||
}
|
||||
|
||||
|
||||
let req_model: String = json_get(body, "model")
|
||||
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
|
||||
|
||||
+8
-4
@@ -24,19 +24,23 @@ ENGRAM_DATA_DIR="$ENGRAM_DATA_DIR" \
|
||||
|
||||
ENGRAM_PID=$!
|
||||
|
||||
# Wait for engram to become healthy (up to 30s)
|
||||
# Wait for engram to become healthy (up to 60s; GKE Autopilot cold starts can be slow)
|
||||
echo "[entrypoint] waiting for engram..."
|
||||
TRIES=0
|
||||
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
|
||||
TRIES=$((TRIES + 1))
|
||||
if [ "$TRIES" -ge 30 ]; then
|
||||
echo "[entrypoint] ERROR: engram did not become healthy after 30s" >&2
|
||||
if [ "$TRIES" -ge 60 ]; then
|
||||
echo "[entrypoint] ERROR: engram did not become healthy after 60s" >&2
|
||||
kill "$ENGRAM_PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[entrypoint] engram ready"
|
||||
echo "[entrypoint] engram ready after ${TRIES}s"
|
||||
|
||||
# Tune EL HTTP runtime: reduce per-call timeout 60s->10s, connect timeout 3s.
|
||||
export EL_HTTP_TIMEOUT_MS="${EL_HTTP_TIMEOUT_MS:-10000}"
|
||||
export EL_HTTP_CONNECT_TIMEOUT_MS="${EL_HTTP_CONNECT_TIMEOUT_MS:-3000}"
|
||||
|
||||
# Start soul — it takes over as PID 1's foreground process.
|
||||
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
// imprint_current — returns the active imprint ID from state.
|
||||
// Falls back to "base" (bare Neuron, no suit) when nothing is loaded.
|
||||
//
|
||||
// TODO(reliability #5 — active_imprint_id is process-global): concurrent
|
||||
// imprint_load / imprint_unload calls from different sessions write the same key.
|
||||
// Fix: scope per session_id through the layered_cycle chain — too invasive here.
|
||||
fn imprint_current() -> String {
|
||||
let id: String = state_get("active_imprint_id")
|
||||
return if str_eq(id, "") { "base" } else { id }
|
||||
|
||||
@@ -46,7 +46,10 @@ fn mem_consolidate() -> String {
|
||||
}
|
||||
|
||||
fn mem_save(path: String) -> Void {
|
||||
engram_save(path)
|
||||
let save_result: String = engram_save(path)
|
||||
if str_eq(save_result, "") {
|
||||
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
fn mem_load(path: String) -> Void {
|
||||
@@ -76,11 +79,14 @@ fn mem_boot_count_inc() -> Int {
|
||||
let next: Int = current + 1
|
||||
let content: String = "soul:boot_count:" + int_to_str(next)
|
||||
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let boot_node_id: String = engram_node_full(
|
||||
content, "Memory", "soul:boot_count",
|
||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||
"Canonical", tags
|
||||
)
|
||||
if str_eq(boot_node_id, "") {
|
||||
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -400,6 +400,7 @@ fn handle_api_log_state_event(body: String) -> String {
|
||||
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||
"Episodic", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
|
||||
}
|
||||
|
||||
@@ -452,6 +453,7 @@ fn handle_api_tune_config(body: String) -> String {
|
||||
let id: String = engram_node_full(content, "ConfigEntry", key,
|
||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||
"Canonical", tags)
|
||||
if !api_persisted(id) { return api_not_persisted(id) }
|
||||
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
|
||||
}
|
||||
|
||||
@@ -651,17 +653,23 @@ fn handle_api_consolidate(body: String) -> String {
|
||||
let summary: String = json_get(body, "summary")
|
||||
let snap: String = state_get("soul_snapshot_path")
|
||||
if !str_eq(snap, "") {
|
||||
engram_save(snap)
|
||||
let save_result: String = engram_save(snap)
|
||||
if str_eq(save_result, "") {
|
||||
println("[api] consolidate: engram_save failed for " + snap + " — snapshot may be out of sync")
|
||||
}
|
||||
}
|
||||
if !str_eq(summary, "") {
|
||||
let safe_summary: String = str_replace(summary, "\"", "'")
|
||||
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
|
||||
let discard: String = engram_node_full(
|
||||
let summary_id: String = engram_node_full(
|
||||
"[session-summary] " + safe_summary,
|
||||
"SessionSummary", "session:summary",
|
||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||
"Episodic", tags
|
||||
)
|
||||
if str_eq(summary_id, "") {
|
||||
println("[api] consolidate: session summary engram write failed — summary node lost")
|
||||
}
|
||||
}
|
||||
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
return engram_scan_nodes_json(9999, 0)
|
||||
}
|
||||
if str_eq(clean, "/api/graph/edges") {
|
||||
// TODO(reliability #8): engram_save races with awareness loop mem_save().
|
||||
// Both now use atomic write-to-temp+rename (el_runtime.c). Serialised
|
||||
// by engram_global_mu. Future: add engram_edges_json() builtin.
|
||||
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
|
||||
engram_save(snap_path)
|
||||
let snap: String = fs_read(snap_path)
|
||||
|
||||
@@ -144,7 +144,8 @@ fn safety_screen(input: String, history: String) -> String {
|
||||
if score >= soft {
|
||||
let summary: String = str_slice(input, 0, 80)
|
||||
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
|
||||
// ISSUE 7: also escape tab chars to prevent JSON envelope corruption.
|
||||
// ISSUE 7 fix: escape tab chars in addition to backslash/quote/newline/CR.
|
||||
// A tab in user input corrupts the JSON envelope and causes json_get to misparse.
|
||||
let e1: String = str_replace(input, "\\", "\\\\")
|
||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||
let e3: String = str_replace(e2, "\n", "\\n")
|
||||
@@ -153,7 +154,7 @@ fn safety_screen(input: String, history: String) -> String {
|
||||
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
||||
}
|
||||
|
||||
// ISSUE 7: also escape tab chars (see soft_bell branch above).
|
||||
// ISSUE 7 fix: escape tab chars (see soft_bell branch above for rationale).
|
||||
let e1: String = str_replace(input, "\\", "\\\\")
|
||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||
let e3: String = str_replace(e2, "\n", "\\n")
|
||||
@@ -199,7 +200,10 @@ fn safety_validate(output: String, action: String) -> String {
|
||||
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
|
||||
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
|
||||
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
|
||||
// ISSUE 2: fallback log when engram write fails silently.
|
||||
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed.
|
||||
// Emit a fallback println so the bell event leaves at least a log trace even
|
||||
// when engram is degraded. This does not replace engram persistence -- it is a
|
||||
// last-resort audit trail when the primary write cannot be confirmed.
|
||||
let node_id: String = engram_node_full(
|
||||
content,
|
||||
"BellEvent",
|
||||
@@ -211,7 +215,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
||||
tags
|
||||
)
|
||||
if str_eq(node_id, "") {
|
||||
println("[safety] WARN: bell engram write failed -- " + content)
|
||||
println("[safety] WARN: bell event engram write failed -- fallback log: " + content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -244,9 +248,16 @@ fn safety_soft_phrases() -> String {
|
||||
}
|
||||
|
||||
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
|
||||
// json_array_len of malformed input returns 0, silently skipping all checks.
|
||||
// Caching requires language-level static const arrays -- not in current EL.
|
||||
// Migrate to const arrays when EL gains that feature.
|
||||
// safety_any_match and safety_count_match loop over json_array_get on every invocation.
|
||||
// A compiled/cached representation would reduce per-message overhead and also guard against
|
||||
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks).
|
||||
// Caching requires language-level static const arrays -- not available in current EL.
|
||||
// When EL gains module-level const arrays, migrate phrase lists to that form.
|
||||
//
|
||||
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
|
||||
// safety_any_match / safety_count_match. json_array_len of a malformed string
|
||||
// returns 0, silently skipping all checks. Caching requires language-level static
|
||||
// const arrays (not available in current EL). Migrate when EL gains that feature.
|
||||
// ── Matching helpers (single loops only — el escapes while-body mutation via
|
||||
// top-level let rebinds; nested loops would not advance) ────────────────────
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ fn session_create(body: String) -> String {
|
||||
// Newest sessions first (prepend).
|
||||
// TODO #4: index update is read-modify-write — two concurrent session_create
|
||||
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
||||
// TODO(reliability #2): session_index RMW is non-atomic. Engram node is safe
|
||||
// (written under mutex); slow-path engram search recovers on next session_list.
|
||||
let existing_idx: String = state_get("session_index")
|
||||
let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}"
|
||||
let new_idx: String = if str_eq(existing_idx, "") {
|
||||
@@ -440,6 +442,8 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
}
|
||||
let oi = oi + 1
|
||||
}
|
||||
// TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
|
||||
// same session can produce orphan history nodes. State is primary truth; engram fallback.
|
||||
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
||||
let discard: String = engram_node_full(
|
||||
hist, "Conversation", "session:messages:" + session_id,
|
||||
|
||||
@@ -296,8 +296,11 @@ fn layered_cycle(raw_input: String) -> String {
|
||||
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
|
||||
state_set("session_continuity", cont_status)
|
||||
// Store continuity status so imprint can adjust its response register.
|
||||
// TODO(reliability #4): session_continuity is process-global; scope per session_id
|
||||
// when available to prevent cross-session bleed under concurrent layered_cycle calls.
|
||||
let cont_key: String = if str_eq(session_id, "") { "session_continuity" } else { "session_continuity:" + session_id }
|
||||
state_set(cont_key, cont_status)
|
||||
|
||||
// Identity anomaly: add a gentle verification cue to the input before imprint
|
||||
let guided: String = if str_eq(cont_action, "identity_check") {
|
||||
|
||||
Reference in New Issue
Block a user