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
|
return combined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO(reliability #10): agentic_conv_history is process-global; awareness loop
|
||||||
|
// and HTTP workers race on this key. Impact: noisy threat score only, not content.
|
||||||
fn threat_history_append(text: String) -> Void {
|
fn threat_history_append(text: String) -> Void {
|
||||||
let current: String = state_get("agentic_conv_history")
|
let current: String = state_get("agentic_conv_history")
|
||||||
let safe_text: String = str_to_lower(text)
|
let safe_text: String = str_to_lower(text)
|
||||||
|
|||||||
@@ -12,113 +12,47 @@ fn chat_default_model() -> String {
|
|||||||
return "claude-sonnet-4-5"
|
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
|
// engram_score_node — compute a recency x relevance score for a single engram
|
||||||
// node JSON object. Higher is better.
|
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
|
||||||
//
|
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
|
||||||
// Bugs fixed vs original implementation:
|
// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5.
|
||||||
// 1. FLOAT PARSING: parse_salience_100 correctly handles %g single-decimal output.
|
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
|
||||||
// "0.7" → 70, "0.6" → 60, "0.5" → 50 (was: 7, 6, 5 — scored near zero and
|
// nodes to the bottom so they get trimmed when we cap context size.
|
||||||
// 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.
|
|
||||||
fn engram_score_node(node_json: String) -> Int {
|
fn engram_score_node(node_json: String) -> Int {
|
||||||
let salience_str: String = json_get(node_json, "salience")
|
let salience_str: String = json_get(node_json, "salience")
|
||||||
let importance_str: String = json_get(node_json, "importance")
|
let importance_str: String = json_get(node_json, "importance")
|
||||||
let created_str: String = json_get(node_json, "created_at")
|
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
|
// Parse as floats via * 100 integer arithmetic (el has no float math)
|
||||||
let salience_100: Int = parse_salience_100(salience_str)
|
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
|
||||||
let importance_100: Int = parse_salience_100(importance_str)
|
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
|
||||||
|
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
|
||||||
// Recency: use max(created_at, updated_at, last_activated).
|
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
||||||
// last_activated is updated by engram_strengthen() every chat turn — nodes
|
}
|
||||||
// actively referenced score fresh regardless of original write time.
|
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
|
||||||
let now_ts: Int = time_now()
|
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
||||||
let created_ts: Int = if str_eq(created_str, "") { 0 } else { str_to_int(created_str) }
|
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compressed recency weight (50 + recency/2): range 65-100 (1.54x dynamic range).
|
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
||||||
// Old formula had 10x recency range which drowned out relevance for old-but-important
|
let now_ts: Int = time_now()
|
||||||
// nodes. New: relevance (0-100) × recency_weight (65-100) / 100 → score 0-100.
|
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
|
||||||
// salience_100 and importance_100 are already in the 0-100 range (parse_salience_100
|
let created_ts: Int = str_to_int(created_str)
|
||||||
// returns e.g. 70 for "0.7"). Dividing by 100 keeps relevance in 0-100.
|
let age_secs: Int = now_ts - created_ts
|
||||||
// Dividing by 10000 caused integer truncation to 0 for all real-world nodes
|
let age_days: Int = age_secs / 86400
|
||||||
// (e.g., sal=0.7, imp=0.7 → 70*70/10000 = 0 instead of 49).
|
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||||
let relevance: Int = salience_100 * importance_100 / 100
|
if decay < 10 { 10 } else { decay }
|
||||||
let recency_weight: Int = 50 + recency_100 / 2
|
}
|
||||||
return relevance * recency_weight / 100
|
|
||||||
|
// 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,
|
// 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.
|
// ordered best-first by score. Only nodes above a minimum score (25 = salience 0.5 *
|
||||||
// With corrected formula (sal*imp/100): sal=0.5*imp=0.5 at max recency scores 25;
|
// importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most
|
||||||
// sal=0.5*imp=0.5 at Working floor (recency=30, weight=65) scores 16.
|
// max_nodes entries concatenated as JSON array text. Because el has no sort primitive,
|
||||||
// Threshold=10 gives safe headroom for low-salience nodes near the recency floor,
|
// we do a single selection pass picking the top N by linear scan (N=10 cap).
|
||||||
// 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).
|
|
||||||
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
|
||||||
if str_eq(nodes_json, "") { return "" }
|
if str_eq(nodes_json, "") { return "" }
|
||||||
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 {
|
while ci < total {
|
||||||
let node: String = json_array_get(nodes_json, ci)
|
let node: String = json_array_get(nodes_json, ci)
|
||||||
let score: Int = engram_score_node(node)
|
let score: Int = engram_score_node(node)
|
||||||
// Threshold=10: allows moderately-relevant older nodes while filtering noise.
|
// Only include reasonably relevant nodes (threshold=25)
|
||||||
// Example: sal=0.5 imp=0.5 at Working recency floor (35+ days) → score 16,
|
let above_thresh: Bool = score >= 25
|
||||||
// which passes. A near-zero node (sal=0.1 imp=0.1) → score ≤ 1, filtered.
|
|
||||||
let above_thresh: Bool = score >= 10
|
|
||||||
// Check this index wasn't already selected (sentinel: look for idx marker)
|
// Check this index wasn't already selected (sentinel: look for idx marker)
|
||||||
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
|
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
|
||||||
let already_picked: Bool = str_contains(selected, idx_marker)
|
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.
|
// 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).
|
// 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.
|
// 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 clean: String = "[" + selected + "]"
|
||||||
let c0: String = str_replace(clean, "\"_sel_0\":1,", "")
|
let c0: String = str_replace(clean, "\"_sel_0\":1,", "")
|
||||||
let c1: String = str_replace(c0, "\"_sel_1\":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 c7: String = str_replace(c6, "\"_sel_7\":1,", "")
|
||||||
let c8: String = str_replace(c7, "\"_sel_8\":1,", "")
|
let c8: String = str_replace(c7, "\"_sel_8\":1,", "")
|
||||||
let c9: String = str_replace(c8, "\"_sel_9\":1,", "")
|
let c9: String = str_replace(c8, "\"_sel_9\":1,", "")
|
||||||
let c10: String = str_replace(c9, "\"_sel_10\":1,", "")
|
return c9
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn engram_compile(intent: String) -> String {
|
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 act_ok: Bool = !str_eq(activate_json, "") && !str_eq(activate_json, "[]")
|
||||||
let srch_ok: Bool = !str_eq(search_json, "") && !str_eq(search_json, "[]")
|
let srch_ok: Bool = !str_eq(search_json, "") && !str_eq(search_json, "[]")
|
||||||
|
|
||||||
// Activation nodes (spreading activation) are high-signal but apply scoring via
|
// Activation nodes (spreading activation) are already high-signal — keep all 5.
|
||||||
// engram_compile_ranked with threshold=5 to exclude genuinely zero-quality stale
|
let act_part: String = if act_ok { activate_json } else { "" }
|
||||||
// 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 { "" }
|
|
||||||
|
|
||||||
// Rank search results and keep only the top 8 (was: flat 15 unranked).
|
// 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.
|
// 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.
|
// 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 state_hist: String = state_get("conv_history")
|
||||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
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) }
|
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_")
|
|| str_starts_with(tool_name, "neuron_")
|
||||||
}
|
}
|
||||||
|
|
||||||
// next_bridge_id — monotonic correlation id for a suspended agentic turn.
|
// next_bridge_id — unique correlation id for a suspended agentic turn.
|
||||||
// Combines boot-relative time with a per-process counter so two unknown-tool
|
// Uses uuid_v4() as the primary uniqueness guarantee — concurrent calls cannot collide.
|
||||||
// suspensions in the same second still get distinct ids.
|
//
|
||||||
|
// 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 {
|
fn next_bridge_id() -> String {
|
||||||
let prev: String = state_get("mcp_bridge_seq")
|
let prev: String = state_get("mcp_bridge_seq")
|
||||||
let n: Int = if str_eq(prev, "") { 0 } else { str_to_int(prev) }
|
let n: Int = if str_eq(prev, "") { 0 } else { str_to_int(prev) }
|
||||||
let next: Int = n + 1
|
let next: Int = n + 1
|
||||||
state_set("mcp_bridge_seq", int_to_str(next))
|
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 {
|
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") {
|
if str_eq(screen_action, "hard_bell") {
|
||||||
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
|
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\":[]}"
|
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
|
||||||
}
|
|
||||||
|
|
||||||
let req_model: String = json_get(body, "model")
|
let req_model: String = json_get(body, "model")
|
||||||
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_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=$!
|
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..."
|
echo "[entrypoint] waiting for engram..."
|
||||||
TRIES=0
|
TRIES=0
|
||||||
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
|
until curl -sf "$ENGRAM_HEALTH_URL" > /dev/null 2>&1; do
|
||||||
TRIES=$((TRIES + 1))
|
TRIES=$((TRIES + 1))
|
||||||
if [ "$TRIES" -ge 30 ]; then
|
if [ "$TRIES" -ge 60 ]; then
|
||||||
echo "[entrypoint] ERROR: engram did not become healthy after 30s" >&2
|
echo "[entrypoint] ERROR: engram did not become healthy after 60s" >&2
|
||||||
kill "$ENGRAM_PID" 2>/dev/null || true
|
kill "$ENGRAM_PID" 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "[entrypoint] engram ready"
|
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.
|
# Start soul — it takes over as PID 1's foreground process.
|
||||||
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
|
# SOUL_ENGRAM_PATH must NOT be set; ENGRAM_URL triggers HTTP mode.
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
|
|
||||||
// imprint_current — returns the active imprint ID from state.
|
// imprint_current — returns the active imprint ID from state.
|
||||||
// Falls back to "base" (bare Neuron, no suit) when nothing is loaded.
|
// Falls back to "base" (bare Neuron, no suit) when nothing is loaded.
|
||||||
|
//
|
||||||
|
// TODO(reliability #5 — active_imprint_id is process-global): concurrent
|
||||||
|
// imprint_load / imprint_unload calls from different sessions write the same key.
|
||||||
|
// Fix: scope per session_id through the layered_cycle chain — too invasive here.
|
||||||
fn imprint_current() -> String {
|
fn imprint_current() -> String {
|
||||||
let id: String = state_get("active_imprint_id")
|
let id: String = state_get("active_imprint_id")
|
||||||
return if str_eq(id, "") { "base" } else { id }
|
return if str_eq(id, "") { "base" } else { id }
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ fn mem_consolidate() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn mem_save(path: String) -> Void {
|
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 {
|
fn mem_load(path: String) -> Void {
|
||||||
@@ -76,11 +79,14 @@ fn mem_boot_count_inc() -> Int {
|
|||||||
let next: Int = current + 1
|
let next: Int = current + 1
|
||||||
let content: String = "soul:boot_count:" + int_to_str(next)
|
let content: String = "soul:boot_count:" + int_to_str(next)
|
||||||
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
|
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
|
||||||
let discard: String = engram_node_full(
|
let boot_node_id: String = engram_node_full(
|
||||||
content, "Memory", "soul:boot_count",
|
content, "Memory", "soul:boot_count",
|
||||||
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
el_from_float(0.9), el_from_float(0.9), el_from_float(1.0),
|
||||||
"Canonical", tags
|
"Canonical", tags
|
||||||
)
|
)
|
||||||
|
if str_eq(boot_node_id, "") {
|
||||||
|
println("[memory] mem_boot_count_inc: engram write failed — boot counter node lost (count=" + int_to_str(next) + ")")
|
||||||
|
}
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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",
|
let id: String = engram_node_full(parts, "InternalStateEvent", "state-event:manual",
|
||||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||||
"Episodic", tags)
|
"Episodic", tags)
|
||||||
|
if !api_persisted(id) { return api_not_persisted(id) }
|
||||||
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
|
return "{\"ok\":true,\"id\":\"" + id + "\",\"boot\":\"" + boot + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +453,7 @@ fn handle_api_tune_config(body: String) -> String {
|
|||||||
let id: String = engram_node_full(content, "ConfigEntry", key,
|
let id: String = engram_node_full(content, "ConfigEntry", key,
|
||||||
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
el_from_float(0.85), el_from_float(0.85), el_from_float(0.9),
|
||||||
"Canonical", tags)
|
"Canonical", tags)
|
||||||
|
if !api_persisted(id) { return api_not_persisted(id) }
|
||||||
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
|
return "{\"ok\":true,\"key\":\"" + key + "\",\"value\":\"" + value + "\",\"id\":\"" + id + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,17 +653,23 @@ fn handle_api_consolidate(body: String) -> String {
|
|||||||
let summary: String = json_get(body, "summary")
|
let summary: String = json_get(body, "summary")
|
||||||
let snap: String = state_get("soul_snapshot_path")
|
let snap: String = state_get("soul_snapshot_path")
|
||||||
if !str_eq(snap, "") {
|
if !str_eq(snap, "") {
|
||||||
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, "") {
|
if !str_eq(summary, "") {
|
||||||
let safe_summary: String = str_replace(summary, "\"", "'")
|
let safe_summary: String = str_replace(summary, "\"", "'")
|
||||||
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
|
let tags: String = "[\"SessionSummary\",\"consolidate\"]"
|
||||||
let discard: String = engram_node_full(
|
let summary_id: String = engram_node_full(
|
||||||
"[session-summary] " + safe_summary,
|
"[session-summary] " + safe_summary,
|
||||||
"SessionSummary", "session:summary",
|
"SessionSummary", "session:summary",
|
||||||
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
||||||
"Episodic", tags
|
"Episodic", tags
|
||||||
)
|
)
|
||||||
|
if str_eq(summary_id, "") {
|
||||||
|
println("[api] consolidate: session summary engram write failed — summary node lost")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
|
return "{\"ok\":true,\"snapshot\":\"" + snap + "\"}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -367,6 +367,9 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
|||||||
return engram_scan_nodes_json(9999, 0)
|
return engram_scan_nodes_json(9999, 0)
|
||||||
}
|
}
|
||||||
if str_eq(clean, "/api/graph/edges") {
|
if str_eq(clean, "/api/graph/edges") {
|
||||||
|
// TODO(reliability #8): engram_save races with awareness loop mem_save().
|
||||||
|
// Both now use atomic write-to-temp+rename (el_runtime.c). Serialised
|
||||||
|
// by engram_global_mu. Future: add engram_edges_json() builtin.
|
||||||
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
|
let snap_path: String = env("HOME") + "/.neuron/engram/snapshot.json"
|
||||||
engram_save(snap_path)
|
engram_save(snap_path)
|
||||||
let snap: String = fs_read(snap_path)
|
let snap: String = fs_read(snap_path)
|
||||||
|
|||||||
@@ -144,7 +144,8 @@ fn safety_screen(input: String, history: String) -> String {
|
|||||||
if score >= soft {
|
if score >= soft {
|
||||||
let summary: String = str_slice(input, 0, 80)
|
let summary: String = str_slice(input, 0, 80)
|
||||||
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
|
let discard: String = safety_log_bell("soft", "wellbeing check needed", summary)
|
||||||
// ISSUE 7: 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 e1: String = str_replace(input, "\\", "\\\\")
|
||||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||||
let e3: String = str_replace(e2, "\n", "\\n")
|
let e3: String = str_replace(e2, "\n", "\\n")
|
||||||
@@ -153,7 +154,7 @@ fn safety_screen(input: String, history: String) -> String {
|
|||||||
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISSUE 7: 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 e1: String = str_replace(input, "\\", "\\\\")
|
||||||
let e2: String = str_replace(e1, "\"", "\\\"")
|
let e2: String = str_replace(e1, "\"", "\\\"")
|
||||||
let e3: String = str_replace(e2, "\n", "\\n")
|
let e3: String = str_replace(e2, "\n", "\\n")
|
||||||
@@ -199,7 +200,10 @@ fn safety_validate(output: String, action: String) -> String {
|
|||||||
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
|
fn safety_log_bell(level: String, reason: String, input_summary: String) -> String {
|
||||||
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
|
let content: String = "BELL:" + level + " | " + reason + " | summary:" + input_summary
|
||||||
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
|
let tags: String = "[\"safety\",\"bell\",\"bell:" + level + "\"]"
|
||||||
// ISSUE 2: 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(
|
let node_id: String = engram_node_full(
|
||||||
content,
|
content,
|
||||||
"BellEvent",
|
"BellEvent",
|
||||||
@@ -211,7 +215,7 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
|||||||
tags
|
tags
|
||||||
)
|
)
|
||||||
if str_eq(node_id, "") {
|
if str_eq(node_id, "") {
|
||||||
println("[safety] WARN: bell engram write failed -- " + content)
|
println("[safety] WARN: bell event engram write failed -- fallback log: " + content)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -244,9 +248,16 @@ fn safety_soft_phrases() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
|
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call.
|
||||||
// json_array_len of malformed input returns 0, silently skipping all checks.
|
// safety_any_match and safety_count_match loop over json_array_get on every invocation.
|
||||||
// Caching requires language-level static const arrays -- not in current EL.
|
// A compiled/cached representation would reduce per-message overhead and also guard against
|
||||||
// Migrate to const arrays when EL gains that feature.
|
// 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
|
// ── Matching helpers (single loops only — el escapes while-body mutation via
|
||||||
// top-level let rebinds; nested loops would not advance) ────────────────────
|
// top-level let rebinds; nested loops would not advance) ────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,8 @@ fn session_create(body: String) -> String {
|
|||||||
// Newest sessions first (prepend).
|
// Newest sessions first (prepend).
|
||||||
// TODO #4: index update is read-modify-write — two concurrent session_create
|
// TODO #4: index update is read-modify-write — two concurrent session_create
|
||||||
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
||||||
|
// TODO(reliability #2): session_index RMW is non-atomic. Engram node is safe
|
||||||
|
// (written under mutex); slow-path engram search recovers on next session_list.
|
||||||
let existing_idx: String = state_get("session_index")
|
let existing_idx: String = state_get("session_index")
|
||||||
let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}"
|
let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}"
|
||||||
let new_idx: String = if str_eq(existing_idx, "") {
|
let new_idx: String = if str_eq(existing_idx, "") {
|
||||||
@@ -440,6 +442,8 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
|||||||
}
|
}
|
||||||
let oi = oi + 1
|
let oi = oi + 1
|
||||||
}
|
}
|
||||||
|
// TODO(reliability #7): delete-then-insert is not atomic — concurrent saves for the
|
||||||
|
// same session can produce orphan history nodes. State is primary truth; engram fallback.
|
||||||
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
||||||
let discard: String = engram_node_full(
|
let discard: String = engram_node_full(
|
||||||
hist, "Conversation", "session:messages:" + session_id,
|
hist, "Conversation", "session:messages:" + session_id,
|
||||||
|
|||||||
@@ -296,8 +296,11 @@ fn layered_cycle(raw_input: String) -> String {
|
|||||||
let cont_status: String = json_get(continuity, "status")
|
let cont_status: String = json_get(continuity, "status")
|
||||||
let cont_action: String = json_get(continuity, "action")
|
let cont_action: String = json_get(continuity, "action")
|
||||||
|
|
||||||
// Store continuity status so imprint can adjust its response register
|
// Store continuity status so imprint can adjust its response register.
|
||||||
state_set("session_continuity", cont_status)
|
// 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
|
// Identity anomaly: add a gentle verification cue to the input before imprint
|
||||||
let guided: String = if str_eq(cont_action, "identity_check") {
|
let guided: String = if str_eq(cont_action, "identity_check") {
|
||||||
|
|||||||
Reference in New Issue
Block a user