Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42bbadcd33 | |||
| b6052f9de3 | |||
| 0113407728 | |||
| 18e040acb1 | |||
| 21f248a33a | |||
| 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,66 +12,34 @@ fn chat_default_model() -> String {
|
|||||||
return "claude-sonnet-4-5"
|
return "claude-sonnet-4-5"
|
||||||
}
|
}
|
||||||
|
|
||||||
// engram_numeric_valid — guard for str_to_int: returns true only when s is a valid
|
|
||||||
// decimal number (integer or single-decimal-point float, optional leading minus).
|
|
||||||
// Q1 fix: rejects "", "null", "N/A", multi-dot strings ("1.2.3"), pure-letter strings.
|
|
||||||
// Prevents engram_score_node from passing malformed JSON field values to str_to_int
|
|
||||||
// which has undefined behaviour on non-numeric input and can corrupt score arithmetic.
|
|
||||||
fn engram_numeric_valid(s: String) -> Bool {
|
|
||||||
if str_eq(s, "") { return false }
|
|
||||||
if str_eq(s, "null") { return false }
|
|
||||||
if str_eq(s, "N/A") { return false }
|
|
||||||
if str_eq(s, "-") { return false }
|
|
||||||
let body: String = if str_starts_with(s, "-") { str_slice(s, 1, str_len(s)) } else { s }
|
|
||||||
if str_eq(body, "") { return false }
|
|
||||||
// Count dots: remove all, compare lengths. Allow at most one dot (float).
|
|
||||||
let no_dot: String = str_replace(body, ".", "")
|
|
||||||
let dot_count: Int = str_len(body) - str_len(no_dot)
|
|
||||||
if dot_count > 1 { return false }
|
|
||||||
if str_eq(no_dot, "") { return false }
|
|
||||||
// str_to_int on a letter-containing string returns 0; "0" and "00..." (e.g. from "0.0")
|
|
||||||
// are valid zeros. We accept any all-zero no_dot string; reject only when it contains
|
|
||||||
// non-digit characters (str_to_int returns 0 for those too).
|
|
||||||
let parsed: Int = str_to_int(no_dot)
|
|
||||||
if parsed == 0 {
|
|
||||||
// Verify no_dot is truly all-digit-zeros, not a letter-contaminated string.
|
|
||||||
// Strip all '0' characters; if anything remains the string is non-numeric.
|
|
||||||
let stripped_zeros: String = str_replace(no_dot, "0", "")
|
|
||||||
if !str_eq(stripped_zeros, "") { return false }
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. Score = salience * importance * recency_factor.
|
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
|
||||||
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
|
// 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.
|
// 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
|
// 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.
|
// nodes to the bottom so they get trimmed when we cap context size.
|
||||||
// Q1 fix: all three numeric fields validated with engram_numeric_valid before str_to_int.
|
|
||||||
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")
|
||||||
|
|
||||||
// Q1 fix: validate before str_to_int. Non-numeric values fall back to safe defaults.
|
// Parse as floats via * 100 integer arithmetic (el has no float math)
|
||||||
// Parse as floats via * 100 integer arithmetic (el has no float math).
|
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
|
||||||
let salience_100: Int = if !engram_numeric_valid(salience_str) { 70 } else {
|
|
||||||
let s: Int = str_to_int(str_replace(salience_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)
|
||||||
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
|
||||||
}
|
}
|
||||||
let importance_100: Int = if !engram_numeric_valid(importance_str) { 70 } else {
|
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
|
||||||
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
|
||||||
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
|
||||||
let now_ts: Int = time_now()
|
let now_ts: Int = time_now()
|
||||||
let recency_100: Int = if !engram_numeric_valid(created_str) { 50 } else {
|
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
|
||||||
let created_ts: Int = str_to_int(created_str)
|
let created_ts: Int = str_to_int(created_str)
|
||||||
let age_secs: Int = now_ts - created_ts
|
let age_secs: Int = now_ts - created_ts
|
||||||
// Q1 fix: guard against clock skew / future timestamps — treat as fresh.
|
let age_days: Int = age_secs / 86400
|
||||||
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
|
|
||||||
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
|
||||||
if decay < 10 { 10 } else { decay }
|
if decay < 10 { 10 } else { decay }
|
||||||
}
|
}
|
||||||
@@ -105,8 +73,9 @@ 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)
|
||||||
// Only include reasonably relevant nodes (threshold=25)
|
// Threshold lowered from 25 to 15: includes moderately-relevant older nodes.
|
||||||
let above_thresh: Bool = score >= 25
|
// A 3-week-old node with salience 0.6 and importance 0.6 scores ~18 — was dropped, now included.
|
||||||
|
let above_thresh: Bool = score >= 15
|
||||||
// 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)
|
||||||
@@ -145,83 +114,256 @@ 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,", "")
|
||||||
return c9
|
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,", "")
|
||||||
|
return c14
|
||||||
}
|
}
|
||||||
|
|
||||||
// Q4 note: engram_compile has no cache or circuit-breaker at the EL layer.
|
// engram_split_topics — split message into sub-queries on explicit conjunctions.
|
||||||
// Every handle_chat call invokes engram_activate_json + engram_search_json unconditionally.
|
// "health goals AND startup progress" becomes two independent searches.
|
||||||
// If the engram backend is repeatedly unreachable (e.g., during startup or after a crash),
|
fn engram_split_topics(message: String) -> String {
|
||||||
// every turn pays two failed RPC round-trips before reaching the cold-start fallback.
|
let sep: String = if str_contains(message, " AND ") { " AND " } else {
|
||||||
// A proper cache/circuit-breaker requires C runtime support (e.g., a shared "engram_healthy"
|
if str_contains(message, " and ") { " and " } else {
|
||||||
// flag set by the runtime, or a time-bucketed result cache in el_runtime.c). At the EL
|
if str_contains(message, " also ") { " also " } else {
|
||||||
// layer we can only detect failure after the fact (empty string return) and log it.
|
if str_contains(message, " plus ") { " plus " } else { "" }
|
||||||
fn engram_compile(intent: String) -> String {
|
}
|
||||||
let activate_json: String = engram_activate_json(intent, 5)
|
|
||||||
// Fetch more search results than we'll use so ranking has a real pool to pick from.
|
|
||||||
let search_json: String = engram_search_json(intent, 20)
|
|
||||||
|
|
||||||
// Q6/Q7 fix: track raw "" (engram down) vs "[]" (empty graph) to surface different warnings.
|
|
||||||
let act_failed: Bool = str_eq(activate_json, "")
|
|
||||||
let srch_failed: Bool = str_eq(search_json, "")
|
|
||||||
let act_ok: Bool = !act_failed && !str_eq(activate_json, "[]")
|
|
||||||
let srch_ok: Bool = !srch_failed && !str_eq(search_json, "[]")
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
let srch_ranked: String = if srch_ok { engram_compile_ranked(search_json, 8) } else { "" }
|
|
||||||
let srch_part: String = srch_ranked
|
|
||||||
|
|
||||||
// Q2 fix: soul-agnostic cold-start fallback. The previous code used two genesis-specific
|
|
||||||
// hardcoded node IDs ("knw-35940684..." and "knw-729fc901..."). Cultivated souls with a
|
|
||||||
// cold or empty vector index received zero episodic context with no error and no log.
|
|
||||||
// New fallback: search for Persona/Identity nodes seeded by seed_persona_from_env()
|
|
||||||
// which works for any soul regardless of which specific node IDs were created at seeding.
|
|
||||||
// Q6 fix: log a warning so the empty-recall path is visible in operator logs.
|
|
||||||
let scan_part: String = if !act_ok && !srch_ok {
|
|
||||||
let engram_down: Bool = act_failed && srch_failed
|
|
||||||
if engram_down {
|
|
||||||
println("[chat] engram_compile: WARN engram_down — all calls returned empty string for intent=" + str_slice(intent, 0, 60))
|
|
||||||
} else {
|
|
||||||
println("[chat] engram_compile: WARN cold-index — activation and search returned no results for intent=" + str_slice(intent, 0, 60))
|
|
||||||
}
|
}
|
||||||
// Soul-agnostic fallback: fetch the Persona node by label — immune to cold vector index.
|
|
||||||
// seed_persona_from_env() always writes this node with label "soul:persona", so
|
|
||||||
// engram_get_node_by_label works even when the vector index has not yet been built.
|
|
||||||
// Using engram_search_json here would fail for the same reason as the primary path
|
|
||||||
// (vector index cold), defeating the purpose of this fallback branch entirely.
|
|
||||||
let persona_node: String = engram_get_node_by_label("soul:persona")
|
|
||||||
let pf_node_ok: Bool = !str_eq(persona_node, "") && !str_eq(persona_node, "null")
|
|
||||||
let persona_arr: String = if pf_node_ok { "[" + persona_node + "]" } else { "" }
|
|
||||||
let pf_ok: Bool = pf_node_ok
|
|
||||||
let combined: String = if pf_ok { engram_compile_ranked(persona_arr, 1) } else { "" }
|
|
||||||
if str_eq(combined, "") {
|
|
||||||
println("[chat] engram_compile: WARN cold-start fallback also empty — LLM has no episodic context")
|
|
||||||
}
|
|
||||||
combined
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
}
|
||||||
let scan_ok: Bool = !str_eq(scan_part, "")
|
if str_eq(sep, "") { return message }
|
||||||
|
let sep_pos: Int = str_index_of(message, sep)
|
||||||
|
let part1: String = str_slice(message, 0, sep_pos)
|
||||||
|
let part2: String = str_slice(message, sep_pos + str_len(sep), str_len(message))
|
||||||
|
let part2_topics: String = engram_split_topics(part2)
|
||||||
|
if str_eq(part1, "") { return part2_topics }
|
||||||
|
return part1 + "\n" + part2_topics
|
||||||
|
}
|
||||||
|
|
||||||
// Affective context: always include the most recent high-emotion memory if one
|
// engram_extract_entities — extract probable named entities (capital-first, 3+ chars,
|
||||||
// exists within 72 hours. This ensures continuity of care across turns — when
|
// not stop-words) from a message. Returns newline-separated list.
|
||||||
// the user was in distress earlier in the session (or recently), that context
|
fn engram_extract_entities(message: String) -> String {
|
||||||
// travels into every subsequent LLM call so the response register stays aware.
|
let stops: String = "|I|A|The|An|In|On|At|To|Of|For|And|But|Or|So|My|Me|We|Us|He|She|It|Is|Are|Was|Were|Has|Have|Had|Do|Does|Did|Can|Could|Will|Would|Should|May|Might|Must|Be|Been|Being|This|That|These|Those|What|When|Where|Who|How|Why|Which|If|Then|Now|Just|Also|Not|No|Yes|Oh|Hi|Hey|Ok|Okay|Please|Thank|Thanks|You|Your|Our|Its|His|Her|Their|Any|All|Some|Get|Got|Let|Say|Think|Know|See|Look|Go|Come|Make|Take|Give|Tell|Ask|Need|Want|Like|Love|Feel|Try|Use|Find|Keep|Put|Set|Run|Start|Stop|Show|Help|Work|Play|Move|Change|Follow|Call|Talk|Check|Remind|Update|Create|Delete|Fix|Add|Remove|Open|Close|Read|Write|Send|Receive|"
|
||||||
// We search for BellEvent nodes specifically; these are written by auto_persist
|
let capitals: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
// when safety_detect_bell_level fires. The 72h window (259200 seconds) is wide
|
let entities: String = ""
|
||||||
// enough to span a multi-session day without pulling ancient history.
|
let entity_count: Int = 0
|
||||||
|
let msg_len: Int = str_len(message)
|
||||||
|
let pos: Int = 0
|
||||||
|
while pos < msg_len && entity_count < 10 {
|
||||||
|
let wend: Int = pos
|
||||||
|
let scanning: Bool = true
|
||||||
|
while scanning && wend < msg_len {
|
||||||
|
let wch: String = str_slice(message, wend, wend + 1)
|
||||||
|
let is_sep: Bool = str_eq(wch, " ") || str_eq(wch, "\n") || str_eq(wch, "\t")
|
||||||
|
|| str_eq(wch, ",") || str_eq(wch, ".") || str_eq(wch, "?")
|
||||||
|
|| str_eq(wch, "!") || str_eq(wch, ":") || str_eq(wch, ";")
|
||||||
|
|| str_eq(wch, "(") || str_eq(wch, ")") || str_eq(wch, "\'") || str_eq(wch, "-")
|
||||||
|
let scanning = if is_sep { false } else { scanning }
|
||||||
|
let wend = if !is_sep { wend + 1 } else { wend }
|
||||||
|
}
|
||||||
|
let word: String = str_slice(message, pos, wend)
|
||||||
|
let word_len: Int = str_len(word)
|
||||||
|
let first_ch: String = if word_len >= 3 { str_slice(word, 0, 1) } else { "" }
|
||||||
|
let is_capital: Bool = word_len >= 3 && str_contains(capitals, first_ch)
|
||||||
|
let is_stop: Bool = str_contains(stops, "|" + word + "|")
|
||||||
|
let already_have: Bool = str_contains(entities, word)
|
||||||
|
let should_add: Bool = is_capital && !is_stop && !already_have && word_len >= 3
|
||||||
|
let entities = if should_add {
|
||||||
|
let entity_count = entity_count + 1
|
||||||
|
if str_eq(entities, "") { word } else { entities + "\n" + word }
|
||||||
|
} else { entities }
|
||||||
|
let pos = if wend > pos { wend + 1 } else { pos + 1 }
|
||||||
|
}
|
||||||
|
return entities
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_detect_recall_intent — true when message explicitly requests memory recall.
|
||||||
|
fn engram_detect_recall_intent(message: String) -> Bool {
|
||||||
|
return str_contains(message, "remind me")
|
||||||
|
|| str_contains(message, "do you remember")
|
||||||
|
|| str_contains(message, "what do you know")
|
||||||
|
|| str_contains(message, "what happened")
|
||||||
|
|| str_contains(message, "tell me about")
|
||||||
|
|| str_contains(message, "what was")
|
||||||
|
|| str_contains(message, "what were")
|
||||||
|
|| str_contains(message, "how is it going")
|
||||||
|
|| str_contains(message, "how are things")
|
||||||
|
|| str_contains(message, "catch me up")
|
||||||
|
|| str_contains(message, "fill me in")
|
||||||
|
|| str_contains(message, "what's the status")
|
||||||
|
|| str_contains(message, "whats the status")
|
||||||
|
|| str_contains(message, "any updates")
|
||||||
|
|| str_contains(message, "recap")
|
||||||
|
|| str_contains(message, "look up")
|
||||||
|
|| str_contains(message, "check on")
|
||||||
|
|| str_contains(message, "how did")
|
||||||
|
|| str_contains(message, "what happened with")
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_is_continuation — semantic continuation detection replacing the brittle 50-char
|
||||||
|
// threshold. Returns true when message starts with a pronoun, continuation opener, or is
|
||||||
|
// < 80 chars (raised from 50 to catch "Can you remind me what Prism's architecture
|
||||||
|
// looks like?" at 57 chars which is clearly a continuation in an active thread).
|
||||||
|
fn engram_is_continuation(message: String, hist_len: Int) -> Bool {
|
||||||
|
if hist_len <= 0 { return false }
|
||||||
|
let has_pronoun: Bool = str_starts_with(message, "It ")
|
||||||
|
|| str_starts_with(message, "it ")
|
||||||
|
|| str_starts_with(message, "That ") || str_starts_with(message, "that ")
|
||||||
|
|| str_starts_with(message, "This ") || str_starts_with(message, "this ")
|
||||||
|
|| str_starts_with(message, "They ") || str_starts_with(message, "they ")
|
||||||
|
|| str_starts_with(message, "He ") || str_starts_with(message, "he ")
|
||||||
|
|| str_starts_with(message, "She ") || str_starts_with(message, "she ")
|
||||||
|
|| str_starts_with(message, "We ") || str_starts_with(message, "we ")
|
||||||
|
if has_pronoun { return true }
|
||||||
|
let is_cont_opener: Bool = str_starts_with(message, "Go on")
|
||||||
|
|| str_starts_with(message, "go on")
|
||||||
|
|| str_starts_with(message, "Continue") || str_starts_with(message, "continue")
|
||||||
|
|| str_starts_with(message, "Yes") || str_starts_with(message, "yes")
|
||||||
|
|| str_starts_with(message, "No,") || str_starts_with(message, "no,")
|
||||||
|
|| str_starts_with(message, "Ok") || str_starts_with(message, "ok")
|
||||||
|
|| str_starts_with(message, "And ") || str_starts_with(message, "and ")
|
||||||
|
|| str_starts_with(message, "But ") || str_starts_with(message, "but ")
|
||||||
|
|| str_starts_with(message, "What about") || str_starts_with(message, "what about")
|
||||||
|
|| str_starts_with(message, "Why ") || str_starts_with(message, "why ")
|
||||||
|
|| str_starts_with(message, "How ") || str_starts_with(message, "how ")
|
||||||
|
|| str_starts_with(message, "When ") || str_starts_with(message, "when ")
|
||||||
|
if is_cont_opener { return true }
|
||||||
|
if str_len(message) < 80 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_compile_multi — run activation + search for one topic with expanded pools.
|
||||||
|
// Activation depth 8 (was 5). Search 30 candidates ranked to 12 (was 20/8).
|
||||||
|
// Per-topic result pool: up to 20 nodes (was 13).
|
||||||
|
fn engram_compile_multi(topic: String) -> String {
|
||||||
|
let activate_json: String = engram_activate_json(topic, 8)
|
||||||
|
let search_json: String = engram_search_json(topic, 30)
|
||||||
|
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 act_nodes: String = if act_ok { activate_json } else { "" }
|
||||||
|
let srch_nodes: String = if srch_ok { engram_compile_ranked(search_json, 12) } else { "" }
|
||||||
|
if !str_eq(act_nodes, "") && !str_eq(srch_nodes, "") {
|
||||||
|
let act_inner: String = str_slice(act_nodes, 1, str_len(act_nodes) - 1)
|
||||||
|
let srch_inner: String = str_slice(srch_nodes, 1, str_len(srch_nodes) - 1)
|
||||||
|
return engram_dedup_nodes("[" + act_inner + "," + srch_inner + "]")
|
||||||
|
}
|
||||||
|
if !str_eq(act_nodes, "") { return act_nodes }
|
||||||
|
if !str_eq(srch_nodes, "") { return srch_nodes }
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_nodes_merge — merge two node arrays, deduplicating by node id.
|
||||||
|
fn engram_nodes_merge(a: String, b: String) -> String {
|
||||||
|
let ok_a: Bool = !str_eq(a, "") && !str_eq(a, "[]")
|
||||||
|
let ok_b: Bool = !str_eq(b, "") && !str_eq(b, "[]")
|
||||||
|
if !ok_a && !ok_b { return "" }
|
||||||
|
if !ok_a { return b }
|
||||||
|
if !ok_b { return a }
|
||||||
|
let ai: String = str_slice(a, 1, str_len(a) - 1)
|
||||||
|
let bi: String = str_slice(b, 1, str_len(b) - 1)
|
||||||
|
return engram_dedup_nodes("[" + ai + "," + bi + "]")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn engram_compile(intent: String) -> String {
|
||||||
|
// Issue 1: decompose multi-topic messages into sub-queries.
|
||||||
|
let topics: String = engram_split_topics(intent)
|
||||||
|
let has_multi_topic: Bool = str_contains(topics, "\n")
|
||||||
|
|
||||||
|
// Issue 4: detect explicit recall intent and run boosted search.
|
||||||
|
let is_recall_intent: Bool = engram_detect_recall_intent(intent)
|
||||||
|
|
||||||
|
// Issue 2: extract named entities for dedicated per-entity searches.
|
||||||
|
let entity_list: String = engram_extract_entities(intent)
|
||||||
|
let has_entities: Bool = !str_eq(entity_list, "")
|
||||||
|
|
||||||
|
// Primary topic search (first or only topic).
|
||||||
|
let topic0: String = if has_multi_topic {
|
||||||
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
|
str_slice(topics, 0, nl0)
|
||||||
|
} else { topics }
|
||||||
|
let nodes0: String = engram_compile_multi(topic0)
|
||||||
|
|
||||||
|
// Second topic segment.
|
||||||
|
let nodes1: String = if has_multi_topic {
|
||||||
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
|
let rest1: String = str_slice(topics, nl0 + 1, str_len(topics))
|
||||||
|
let nl1: Int = str_index_of(rest1, "\n")
|
||||||
|
let topic1: String = if nl1 < 0 { rest1 } else { str_slice(rest1, 0, nl1) }
|
||||||
|
if str_eq(topic1, "") { "" } else { engram_compile_multi(topic1) }
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Third topic segment.
|
||||||
|
let nodes2: String = if has_multi_topic {
|
||||||
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
|
let rest1: String = str_slice(topics, nl0 + 1, str_len(topics))
|
||||||
|
let nl1: Int = str_index_of(rest1, "\n")
|
||||||
|
if nl1 < 0 { "" } else {
|
||||||
|
let rest2: String = str_slice(rest1, nl1 + 1, str_len(rest1))
|
||||||
|
let nl2: Int = str_index_of(rest2, "\n")
|
||||||
|
let topic2: String = if nl2 < 0 { rest2 } else { str_slice(rest2, 0, nl2) }
|
||||||
|
if str_eq(topic2, "") { "" } else { engram_compile_multi(topic2) }
|
||||||
|
}
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Issue 2 cont.: entity 0 dedicated search (15 candidates, ranked 6).
|
||||||
|
let entity_nodes0: String = if has_entities {
|
||||||
|
let nl_e0: Int = str_index_of(entity_list, "\n")
|
||||||
|
let entity0: String = if nl_e0 < 0 { entity_list } else { str_slice(entity_list, 0, nl_e0) }
|
||||||
|
if str_eq(entity0, "") { "" } else {
|
||||||
|
let ent_srch: String = engram_search_json(entity0, 15)
|
||||||
|
let ent_ok: Bool = !str_eq(ent_srch, "") && !str_eq(ent_srch, "[]")
|
||||||
|
if ent_ok { engram_compile_ranked(ent_srch, 6) } else { "" }
|
||||||
|
}
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Entity 1 dedicated search.
|
||||||
|
let entity_nodes1: String = if has_entities {
|
||||||
|
let nl_e0: Int = str_index_of(entity_list, "\n")
|
||||||
|
if nl_e0 < 0 { "" } else {
|
||||||
|
let rest_e: String = str_slice(entity_list, nl_e0 + 1, str_len(entity_list))
|
||||||
|
let nl_e1: Int = str_index_of(rest_e, "\n")
|
||||||
|
let entity1: String = if nl_e1 < 0 { rest_e } else { str_slice(rest_e, 0, nl_e1) }
|
||||||
|
if str_eq(entity1, "") { "" } else {
|
||||||
|
let ent_srch1: String = engram_search_json(entity1, 15)
|
||||||
|
let ent1_ok: Bool = !str_eq(ent_srch1, "") && !str_eq(ent_srch1, "[]")
|
||||||
|
if ent1_ok { engram_compile_ranked(ent_srch1, 6) } else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Issue 4 cont.: boosted search for recall-intent (40 candidates, ranked 15).
|
||||||
|
let recall_boost: String = if is_recall_intent {
|
||||||
|
let boost_srch: String = engram_search_json(intent, 40)
|
||||||
|
let boost_ok: Bool = !str_eq(boost_srch, "") && !str_eq(boost_srch, "[]")
|
||||||
|
if boost_ok { engram_compile_ranked(boost_srch, 15) } else { "" }
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Merge all pools, deduplicating at each step.
|
||||||
|
let merged: String = engram_nodes_merge(nodes0, nodes1)
|
||||||
|
let merged: String = engram_nodes_merge(merged, nodes2)
|
||||||
|
let merged: String = engram_nodes_merge(merged, entity_nodes0)
|
||||||
|
let merged: String = engram_nodes_merge(merged, entity_nodes1)
|
||||||
|
let merged: String = engram_nodes_merge(merged, recall_boost)
|
||||||
|
let merged_nodes: String = merged
|
||||||
|
|
||||||
|
// Fallback: when all searches return nothing, fetch persona nodes.
|
||||||
|
let scan_part: String = if str_eq(merged_nodes, "") || str_eq(merged_nodes, "[]") {
|
||||||
|
let persona_fallback: String = engram_search_json("soul:persona Persona identity", 5)
|
||||||
|
let pf_ok: Bool = !str_eq(persona_fallback, "") && !str_eq(persona_fallback, "[]")
|
||||||
|
if pf_ok {
|
||||||
|
let pf_ranked: String = engram_compile_ranked(persona_fallback, 3)
|
||||||
|
if str_eq(pf_ranked, "") { "" } else { pf_ranked }
|
||||||
|
} else { "" }
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
// Affective context: always include the most recent high-emotion memory within 72h.
|
||||||
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
|
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
|
||||||
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
|
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
|
||||||
let now_ts: Int = time_now()
|
let now_ts: Int = time_now()
|
||||||
let cutoff_ts: Int = now_ts - 259200
|
let cutoff_ts: Int = now_ts - 259200
|
||||||
let recent_bell: String = if bell_ok {
|
let recent_bell: String = if bell_ok {
|
||||||
let bn0: String = json_array_get(bell_nodes, 0)
|
let bn0: String = json_array_get(bell_nodes, 0)
|
||||||
// created_at is not present in engram node JSON for BellEvent nodes.
|
|
||||||
// Extract the timestamp embedded in the content string as " | ts:NNNNN".
|
|
||||||
// Fall back to created_at / updated_at JSON fields if the marker is absent.
|
|
||||||
let bn_content: String = json_get(bn0, "content")
|
let bn_content: String = json_get(bn0, "content")
|
||||||
let ts_marker: String = " | ts:"
|
let ts_marker: String = " | ts:"
|
||||||
let ts_pos: Int = str_index_of(bn_content, ts_marker)
|
let ts_pos: Int = str_index_of(bn_content, ts_marker)
|
||||||
@@ -234,39 +376,60 @@ fn engram_compile(intent: String) -> String {
|
|||||||
let ca: String = json_get(bn0, "created_at")
|
let ca: String = json_get(bn0, "created_at")
|
||||||
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
|
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
|
||||||
}
|
}
|
||||||
// Q1 fix: validate bell timestamp before str_to_int.
|
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
|
||||||
let bn_ts: Int = if !engram_numeric_valid(bn_ts_raw) { 0 } else { str_to_int(bn_ts_raw) }
|
|
||||||
if bn_ts > cutoff_ts { bn0 } else { "" }
|
if bn_ts > cutoff_ts { bn0 } else { "" }
|
||||||
} else { "" }
|
} else { "" }
|
||||||
let affective_part: String = if !str_eq(recent_bell, "") { recent_bell } else { "" }
|
// Positive emotion context: check for recent joy/success moments within 72h.
|
||||||
let affective_ok: Bool = !str_eq(affective_part, "")
|
let pos_ec_nodes: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
|
||||||
|
let pos_ec_ok: Bool = !str_eq(pos_ec_nodes, "") && !str_eq(pos_ec_nodes, "[]")
|
||||||
let sep1: String = if !str_eq(act_part, "") && !str_eq(srch_part, "") { "\n" } else { "" }
|
let recent_positive_ec: String = if pos_ec_ok {
|
||||||
let sep2: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "")) && !str_eq(scan_part, "") { "\n" } else { "" }
|
let pec0: String = json_array_get(pos_ec_nodes, 0)
|
||||||
let sep3: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "") || !str_eq(scan_part, "")) && !str_eq(affective_part, "") { "\n" } else { "" }
|
let pec_content: String = json_get(pec0, "content")
|
||||||
let ctx: String = act_part + sep1 + srch_part + sep2 + scan_part + sep3 + affective_part
|
let pec_ts_marker: String = " | ts:"
|
||||||
|
let pec_ts_pos: Int = str_index_of(pec_content, pec_ts_marker)
|
||||||
// Q7 fix: store recall status so build_system_prompt can include a hint to the LLM
|
let pec_ts_raw: String = if pec_ts_pos >= 0 {
|
||||||
// distinguishing "no memories yet" (cold start) from "memory system unreachable".
|
let pec_ts_start: Int = pec_ts_pos + str_len(pec_ts_marker)
|
||||||
// Values: "ok" | "empty" | "unavailable"
|
let pec_rest: String = str_slice(pec_content, pec_ts_start, str_len(pec_content))
|
||||||
let any_ok: Bool = act_ok || srch_ok || scan_ok || affective_ok
|
let pec_next: Int = str_index_of(pec_rest, " | ")
|
||||||
let all_failed: Bool = act_failed && srch_failed
|
if pec_next < 0 { pec_rest } else { str_slice(pec_rest, 0, pec_next) }
|
||||||
let recall_status: String = if any_ok { "ok" } else { if all_failed { "unavailable" } else { "empty" } }
|
} else {
|
||||||
state_set("engram_recall_status", recall_status)
|
let pec_ca: String = json_get(pec0, "created_at")
|
||||||
|
if str_eq(pec_ca, "") { json_get(pec0, "updated_at") } else { pec_ca }
|
||||||
if str_eq(ctx, "") {
|
}
|
||||||
// Q6 fix: log when ctx is empty after all recall paths so cold-start is visible.
|
let pec_ts: Int = if str_eq(pec_ts_raw, "") { 0 } else { str_to_int(pec_ts_raw) }
|
||||||
println("[chat] engram_compile: all paths empty — recall_status=" + recall_status + " intent=" + str_slice(intent, 0, 60))
|
if pec_ts > cutoff_ts { pec0 } else { "" }
|
||||||
return ""
|
} else { "" }
|
||||||
|
let affective_part: String = if !str_eq(recent_bell, "") {
|
||||||
|
recent_bell
|
||||||
|
} else {
|
||||||
|
if !str_eq(recent_positive_ec, "") { recent_positive_ec } else { "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raise the cap slightly to match the ranked (higher-signal) output.
|
let has_main: Bool = !str_eq(merged_nodes, "") && !str_eq(merged_nodes, "[]")
|
||||||
if str_len(ctx) > 6000 {
|
let main_part: String = if has_main { merged_nodes } else { scan_part }
|
||||||
return str_slice(ctx, 0, 6000)
|
let sep_ma: String = if !str_eq(main_part, "") && !str_eq(affective_part, "") { "\n" } else { "" }
|
||||||
|
let ctx: String = main_part + sep_ma + affective_part
|
||||||
|
|
||||||
|
if str_eq(ctx, "") { return "" }
|
||||||
|
|
||||||
|
// Issue 7 fix: safe JSON truncation — find last closing brace before budget cap.
|
||||||
|
// Budget raised from 6000 to 8000 for the larger multi-topic pool.
|
||||||
|
let budget: Int = 8000
|
||||||
|
if str_len(ctx) <= budget { return ctx }
|
||||||
|
let search_end: Int = budget - 1
|
||||||
|
let scan_limit: Int = if search_end > 500 { search_end - 500 } else { 0 }
|
||||||
|
let found_pos: Int = -1
|
||||||
|
let si: Int = search_end
|
||||||
|
while si >= scan_limit {
|
||||||
|
let ch: String = str_slice(ctx, si, si + 1)
|
||||||
|
let found_pos = if str_eq(ch, "}") && found_pos < 0 { si } else { found_pos }
|
||||||
|
let si = if found_pos >= 0 { scan_limit - 1 } else { si - 1 }
|
||||||
}
|
}
|
||||||
return ctx
|
if found_pos < 0 { return str_slice(ctx, 0, budget) }
|
||||||
|
let truncated: String = str_slice(ctx, 0, found_pos + 1)
|
||||||
|
if str_starts_with(ctx, "[") { return truncated + "]" }
|
||||||
|
return truncated
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_safe(s: String) -> String {
|
fn json_safe(s: String) -> String {
|
||||||
let s1: String = str_replace(s, "\\", "\\\\")
|
let s1: String = str_replace(s, "\\", "\\\\")
|
||||||
let s2: String = str_replace(s1, "\"", "\\\"")
|
let s2: String = str_replace(s1, "\"", "\\\"")
|
||||||
@@ -297,33 +460,12 @@ fn build_system_prompt(ctx: String) -> String {
|
|||||||
"\n\n[IDENTITY GRAPH — who you are, loaded from your engram]\n" + id_ctx
|
"\n\n[IDENTITY GRAPH — who you are, loaded from your engram]\n" + id_ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Q7 fix: if recall produced no results, include a hint so the LLM can respond
|
|
||||||
// authentically ("I seem to be starting fresh" vs "memory system may be down")
|
|
||||||
// rather than silently acting as if it has context it doesn't have.
|
|
||||||
// Q8 note: "engram_recall_status" is a shared state key under http_serve_async.
|
|
||||||
// Concurrent requests can overwrite each other's status. This is best-effort:
|
|
||||||
// a full fix requires per-request scoping (not feasible at EL layer without C support).
|
|
||||||
let recall_status: String = state_get("engram_recall_status")
|
|
||||||
let engram_block: String = if str_eq(ctx, "") {
|
let engram_block: String = if str_eq(ctx, "") {
|
||||||
let status_hint: String = if str_eq(recall_status, "unavailable") {
|
""
|
||||||
"\n\n[MEMORY STATUS]\nYour episodic memory system appears to be temporarily unreachable. You may not have access to memories from previous sessions. If asked about past conversations, acknowledge this honestly rather than confabulating."
|
|
||||||
} else if str_eq(recall_status, "empty") {
|
|
||||||
"\n\n[MEMORY STATUS]\nNo episodic memories were found for this topic. This may be a new soul or a new area of conversation. Respond naturally from your identity without fabricating memories."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
status_hint
|
|
||||||
} else {
|
} else {
|
||||||
"\n\n[ENGRAM CONTEXT — compiled from your graph]\n" + ctx
|
"\n\n[ENGRAM CONTEXT — compiled from your graph]\n" + ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Q8 note: layered_cycle_safety_system_addendum is a shared mutable state key.
|
|
||||||
// Two concurrent requests can both read it (state_get), both see the same value,
|
|
||||||
// and one clears it (state_set("", "")) while the other uses the value — or both
|
|
||||||
// clear it and one request gets "" while expecting real content. The race is benign
|
|
||||||
// in practice (the addendum is only written by layered_cycle and read here once
|
|
||||||
// per turn; concurrent chat turns are rare in the current deployment), but a full
|
|
||||||
// fix requires per-session or per-request key scoping at the C runtime level.
|
|
||||||
let safety_addendum: String = state_get("layered_cycle_safety_system_addendum")
|
let safety_addendum: String = state_get("layered_cycle_safety_system_addendum")
|
||||||
let safety_block: String = if str_eq(safety_addendum, "") {
|
let safety_block: String = if str_eq(safety_addendum, "") {
|
||||||
""
|
""
|
||||||
@@ -332,7 +474,7 @@ fn build_system_prompt(ctx: String) -> String {
|
|||||||
safety_addendum
|
safety_addendum
|
||||||
}
|
}
|
||||||
|
|
||||||
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + engram_block + safety_block
|
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + affective_boot_block + engram_block + safety_block
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hist_append(hist: String, role: String, content: String) -> String {
|
fn hist_append(hist: String, role: String, content: String) -> String {
|
||||||
@@ -438,92 +580,51 @@ fn clean_llm_response(s: String) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// conv_history_persist — save conversation history to engram for cross-restart continuity.
|
// conv_history_persist — save conversation history to engram for cross-restart continuity.
|
||||||
// Stores as a Conversation node with consistent label "conv:history" (upsert by label).
|
// Stores as a Conversation node. Overwrites by using consistent label "conv:history".
|
||||||
// Q3/Q6 fix: added partial-write guard and failure logging.
|
|
||||||
fn conv_history_persist(hist: String) -> Void {
|
fn conv_history_persist(hist: String) -> Void {
|
||||||
if str_eq(hist, "") { return "" }
|
if str_eq(hist, "") { return "" }
|
||||||
if str_eq(hist, "[]") { return "" }
|
if str_eq(hist, "[]") { return "" }
|
||||||
// Partial-write guard: refuse to persist a blob that is not a complete JSON array.
|
let ts: Int = time_now()
|
||||||
// A truncated write starting with '[' but missing the closing ']' must be rejected.
|
|
||||||
// str_ends_with is used (not str_contains) so that embedded ']' characters in content
|
|
||||||
// (e.g. "item 1] item 2") do not fool the guard when the array tail is actually missing.
|
|
||||||
if !str_starts_with(hist, "[") { return "" }
|
|
||||||
if !str_ends_with(hist, "]") { return "" }
|
|
||||||
let tags: String = "[\"conv-history\",\"persistent\"]"
|
let tags: String = "[\"conv-history\",\"persistent\"]"
|
||||||
let node_id: String = engram_node_full(
|
let discard: String = engram_node_full(
|
||||||
hist, "Conversation", "conv:history",
|
hist, "Conversation", "conv:history",
|
||||||
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
|
el_from_float(0.7), el_from_float(0.8), el_from_float(0.9),
|
||||||
"Episodic", tags
|
"Episodic", tags
|
||||||
)
|
)
|
||||||
// Q6 fix: log write failure — silent history loss is now visible.
|
|
||||||
if str_eq(node_id, "") {
|
|
||||||
println("[chat] conv_history_persist: engram_node_full returned empty — history node may be lost")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// conv_history_load — restore conversation history from engram on first access.
|
// conv_history_load — restore conversation history from engram on first access.
|
||||||
// Q3/Q6 fix: added partial-write guard, log on invalid content, and state flag for
|
// Returns the most recent "conv:history" node content, or "" if none found.
|
||||||
// callers to distinguish genuine first-turn from a load failure.
|
|
||||||
fn conv_history_load() -> String {
|
fn conv_history_load() -> String {
|
||||||
// Primary: label-based fetch — symmetric with persist, immune to vector index drift.
|
|
||||||
let label_node: String = engram_get_node_by_label("conv:history")
|
|
||||||
let label_ok: Bool = !str_eq(label_node, "") && !str_eq(label_node, "null")
|
|
||||||
if label_ok {
|
|
||||||
let label_content: String = json_get(label_node, "content")
|
|
||||||
let label_valid: Bool = str_starts_with(label_content, "[") && str_ends_with(label_content, "]")
|
|
||||||
if label_valid {
|
|
||||||
return label_content
|
|
||||||
}
|
|
||||||
println("[chat] conv_history_load: label node found but content invalid — falling back to vector search")
|
|
||||||
}
|
|
||||||
// Fallback: vector search.
|
|
||||||
let results: String = engram_search_json("conv:history", 3)
|
let results: String = engram_search_json("conv:history", 3)
|
||||||
if str_eq(results, "") {
|
if str_eq(results, "") { return "" }
|
||||||
// Q3 fix: set a state flag so callers can distinguish load failure from first turn.
|
|
||||||
state_set("conv_history_load_failed", "1")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if str_eq(results, "[]") { return "" }
|
if str_eq(results, "[]") { return "" }
|
||||||
let node: String = json_array_get(results, 0)
|
let node: String = json_array_get(results, 0)
|
||||||
let content: String = json_get(node, "content")
|
let content: String = json_get(node, "content")
|
||||||
// Partial-write guard: require both '[' prefix AND closing ']' at the tail.
|
// Validate it looks like a JSON array
|
||||||
// str_ends_with guards against embedded ']' in content fooling the check.
|
if !str_starts_with(content, "[") { return "" }
|
||||||
if !str_starts_with(content, "[") || !str_ends_with(content, "]") {
|
|
||||||
println("[chat] conv_history_load: vector search result content invalid — treating as first turn")
|
|
||||||
state_set("conv_history_load_failed", "1")
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_chat(body: String) -> String {
|
fn handle_chat(body: String) -> String {
|
||||||
let message: String = json_get(body, "message")
|
let message: String = json_get(body, "message")
|
||||||
if str_eq(message, "") {
|
if str_eq(message, "") {
|
||||||
return "{\"__status__\":400,\"error\":\"message is required\",\"response\":\"\"}"
|
return "{\"error\":\"message is required\",\"response\":\"\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
// Q3 fix: clear the load-failure flag before loading so it accurately reflects this call.
|
// TODO(reliability #3 — conv_history global race): process-global key; concurrent
|
||||||
state_set("conv_history_load_failed", "")
|
// /api/chat requests without session_id race on this read-append-write.
|
||||||
// Q8 note: "conv_history" is a process-global state key. Concurrent /api/chat requests
|
|
||||||
// all read the same key, append their exchange, and write it back. Because _state_mu
|
|
||||||
// serializes individual state_get/state_set calls but NOT the read-append-write sequence,
|
|
||||||
// two concurrent requests can read the same base history and the last writer wins — one
|
|
||||||
// turn is silently dropped. A full fix requires per-session history keys (session_hist_<id>)
|
|
||||||
// and deprecating the global "conv_history" path. Callers using session_id are not affected.
|
|
||||||
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_load_failed: Bool = str_eq(state_get("conv_history_load_failed"), "1")
|
|
||||||
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) }
|
||||||
|
|
||||||
// Thread-aware activation: short/ambiguous messages (continuations like "go on",
|
// Issue 8 fix: use semantic continuation detection instead of brittle 50-char threshold.
|
||||||
// "what else?", "yes") activate on the last reply instead of the bare message.
|
let is_continuation: Bool = engram_is_continuation(message, hist_len)
|
||||||
// This prevents a strong off-topic memory node from hijacking the reply when the
|
|
||||||
// user is clearly continuing an existing thread.
|
|
||||||
let is_continuation: Bool = str_len(message) < 50 && hist_len > 0
|
|
||||||
let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" }
|
let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" }
|
||||||
let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" }
|
let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" }
|
||||||
let thread_snip: String = if str_len(last_content) > 150 { str_slice(last_content, 0, 150) } else { last_content }
|
// Thread snip extended 150->250 chars for better pronoun resolution context.
|
||||||
|
let thread_snip: String = if str_len(last_content) > 250 { str_slice(last_content, 0, 250) } else { last_content }
|
||||||
let activation_seed: String = if !str_eq(thread_snip, "") {
|
let activation_seed: String = if !str_eq(thread_snip, "") {
|
||||||
thread_snip + " " + message
|
thread_snip + " " + message
|
||||||
} else {
|
} else {
|
||||||
@@ -532,91 +633,160 @@ fn handle_chat(body: String) -> String {
|
|||||||
|
|
||||||
// Cross-session affective context: on session start (no history yet), check engram
|
// Cross-session affective context: on session start (no history yet), check engram
|
||||||
// for recent distress signals within 72h and prepend a care directive if found.
|
// for recent distress signals within 72h and prepend a care directive if found.
|
||||||
let affective_prefix: String = if hist_len == 0 {
|
let affective_prefix: String = {
|
||||||
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
|
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
|
||||||
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
|
let aff_now_ts: Int = time_now()
|
||||||
let now_ts: Int = time_now()
|
let aff_cutoff: Int = aff_now_ts - 259200
|
||||||
let cutoff: Int = now_ts - 259200
|
let boot_aff: String = state_get("soul_affective_context")
|
||||||
let found_recent: Bool = if has_nodes {
|
let has_boot_aff: Bool = !str_eq(boot_aff, "")
|
||||||
let dn0: String = json_array_get(distress_nodes, 0)
|
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||||
let ts0_raw: String = json_get(dn0, "created_at")
|
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
|
||||||
let ts0_str: String = if str_eq(ts0_raw, "") { json_get(dn0, "updated_at") } else { ts0_raw }
|
let found_recent_dist: Bool = if has_boot_aff {
|
||||||
let ts0: Int = if str_eq(ts0_str, "") { 0 } else { str_to_int(ts0_str) }
|
true
|
||||||
ts0 > cutoff
|
} else {
|
||||||
|
if has_dist_aff {
|
||||||
|
let dn0: String = json_array_get(dist_nodes_aff, 0)
|
||||||
|
let dn_content: String = json_get(dn0, "content")
|
||||||
|
let daff_marker: String = " | ts:"
|
||||||
|
let daff_pos: Int = str_index_of(dn_content, daff_marker)
|
||||||
|
let daff_ts_str: String = if daff_pos >= 0 {
|
||||||
|
let daff_start: Int = daff_pos + str_len(daff_marker)
|
||||||
|
let daff_rest: String = str_slice(dn_content, daff_start, str_len(dn_content))
|
||||||
|
let daff_next: Int = str_index_of(daff_rest, " | ")
|
||||||
|
if daff_next < 0 { daff_rest } else { str_slice(daff_rest, 0, daff_next) }
|
||||||
|
} else {
|
||||||
|
let daff_ca: String = json_get(dn0, "created_at")
|
||||||
|
if str_eq(daff_ca, "") { json_get(dn0, "updated_at") } else { daff_ca }
|
||||||
|
}
|
||||||
|
let daff_ts: Int = if str_eq(daff_ts_str, "") { 0 } else { str_to_int(daff_ts_str) }
|
||||||
|
daff_ts > aff_cutoff
|
||||||
|
} else { false }
|
||||||
|
}
|
||||||
|
let pos_nodes_aff: String = engram_search_json("PositiveEvent joy:high joy:low affective", 3)
|
||||||
|
let has_pos_aff: Bool = !str_eq(pos_nodes_aff, "") && !str_eq(pos_nodes_aff, "[]")
|
||||||
|
let found_recent_pos: Bool = if has_pos_aff && !found_recent_dist {
|
||||||
|
let pn0: String = json_array_get(pos_nodes_aff, 0)
|
||||||
|
let pn_content: String = json_get(pn0, "content")
|
||||||
|
let paff_marker: String = " | ts:"
|
||||||
|
let paff_pos: Int = str_index_of(pn_content, paff_marker)
|
||||||
|
let paff_ts_str: String = if paff_pos >= 0 {
|
||||||
|
let paff_start: Int = paff_pos + str_len(paff_marker)
|
||||||
|
let paff_rest: String = str_slice(pn_content, paff_start, str_len(pn_content))
|
||||||
|
let paff_next: Int = str_index_of(paff_rest, " | ")
|
||||||
|
if paff_next < 0 { paff_rest } else { str_slice(paff_rest, 0, paff_next) }
|
||||||
|
} else {
|
||||||
|
let paff_ca: String = json_get(pn0, "created_at")
|
||||||
|
if str_eq(paff_ca, "") { json_get(pn0, "updated_at") } else { paff_ca }
|
||||||
|
}
|
||||||
|
let paff_ts: Int = if str_eq(paff_ts_str, "") { 0 } else { str_to_int(paff_ts_str) }
|
||||||
|
paff_ts > aff_cutoff
|
||||||
} else { false }
|
} else { false }
|
||||||
if found_recent {
|
if found_recent_dist {
|
||||||
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
||||||
} else { "" }
|
} else {
|
||||||
} else { "" }
|
if found_recent_pos {
|
||||||
|
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
|
||||||
|
} else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let ctx: String = engram_compile(activation_seed)
|
let ctx: String = engram_compile(activation_seed)
|
||||||
let system: String = affective_prefix + build_system_prompt(ctx)
|
let system: String = affective_prefix + build_system_prompt(ctx)
|
||||||
|
|
||||||
// First message of the session: proactively load user profile and active work context.
|
// Issue 9 fix: add project-specific and session-summary searches to session preload.
|
||||||
// These two searches give the soul grounding before any conversation history exists.
|
// Old hardcoded "user profile" and "in_progress active project" miss project-specific
|
||||||
// Results are rendered as brief bullets — not raw JSON — so they don't inflate context.
|
// nodes stored under names like "Prism" unless those exact words appear in content.
|
||||||
let session_preload: String = if hist_len == 0 {
|
let session_preload: String = if hist_len == 0 {
|
||||||
let profile_nodes: String = engram_search_json("user profile identity preferences", 5)
|
let profile_nodes: String = engram_search_json("user profile identity preferences", 5)
|
||||||
let work_nodes: String = engram_search_json("in_progress active project", 5)
|
let work_nodes: String = engram_search_json("in_progress active project work", 5)
|
||||||
|
let project_nodes: String = engram_search_json("project status current ongoing active", 5)
|
||||||
|
let summary_nodes: String = engram_search_json("SessionSummary session:summary previous-session recent", 3)
|
||||||
|
|
||||||
let profile_ok: Bool = !str_eq(profile_nodes, "") && !str_eq(profile_nodes, "[]")
|
let profile_ok: Bool = !str_eq(profile_nodes, "") && !str_eq(profile_nodes, "[]")
|
||||||
let work_ok: Bool = !str_eq(work_nodes, "") && !str_eq(work_nodes, "[]")
|
let work_ok: Bool = !str_eq(work_nodes, "") && !str_eq(work_nodes, "[]")
|
||||||
|
let project_ok: Bool = !str_eq(project_nodes, "") && !str_eq(project_nodes, "[]")
|
||||||
|
let summary_ok: Bool = !str_eq(summary_nodes, "") && !str_eq(summary_nodes, "[]")
|
||||||
|
|
||||||
// Extract content fields and render as bullet points (one per node, first 120 chars).
|
|
||||||
let profile_bullets: String = if profile_ok {
|
let profile_bullets: String = if profile_ok {
|
||||||
let pn: Int = json_array_len(profile_nodes)
|
let pn: Int = json_array_len(profile_nodes)
|
||||||
let bullets: String = ""
|
let bullets: String = ""
|
||||||
let pi: Int = 0
|
let bullets = if pn > 0 {
|
||||||
// Collect up to 3 profile bullets
|
|
||||||
let bullets = if pi < pn {
|
|
||||||
let n0: String = json_array_get(profile_nodes, 0)
|
let n0: String = json_array_get(profile_nodes, 0)
|
||||||
let c0: String = json_get(n0, "content")
|
let c0: String = json_get(n0, "content")
|
||||||
let snip0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 }
|
let s0: String = if str_len(c0) > 120 { str_slice(c0, 0, 120) } else { c0 }
|
||||||
if str_eq(snip0, "") { bullets } else { "- " + snip0 }
|
if str_eq(s0, "") { bullets } else { "- " + s0 }
|
||||||
} else { bullets }
|
} else { bullets }
|
||||||
let bullets = if pn > 1 {
|
let bullets = if pn > 1 {
|
||||||
let n1: String = json_array_get(profile_nodes, 1)
|
let n1: String = json_array_get(profile_nodes, 1)
|
||||||
let c1: String = json_get(n1, "content")
|
let c1: String = json_get(n1, "content")
|
||||||
let snip1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 }
|
let s1: String = if str_len(c1) > 120 { str_slice(c1, 0, 120) } else { c1 }
|
||||||
if str_eq(snip1, "") { bullets } else { bullets + "\n- " + snip1 }
|
if str_eq(s1, "") { bullets } else { bullets + "\n- " + s1 }
|
||||||
} else { bullets }
|
} else { bullets }
|
||||||
let bullets = if pn > 2 {
|
let bullets = if pn > 2 {
|
||||||
let n2: String = json_array_get(profile_nodes, 2)
|
let n2: String = json_array_get(profile_nodes, 2)
|
||||||
let c2: String = json_get(n2, "content")
|
let c2: String = json_get(n2, "content")
|
||||||
let snip2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 }
|
let s2: String = if str_len(c2) > 120 { str_slice(c2, 0, 120) } else { c2 }
|
||||||
if str_eq(snip2, "") { bullets } else { bullets + "\n- " + snip2 }
|
if str_eq(s2, "") { bullets } else { bullets + "\n- " + s2 }
|
||||||
} else { bullets }
|
} else { bullets }
|
||||||
bullets
|
bullets
|
||||||
} else { "" }
|
} else { "" }
|
||||||
|
|
||||||
let work_bullets: String = if work_ok {
|
let work_bullets: String = if work_ok {
|
||||||
let wn: Int = json_array_len(work_nodes)
|
let wn: Int = json_array_len(work_nodes)
|
||||||
let wbullets: String = ""
|
let wb: String = ""
|
||||||
let wbullets = if wn > 0 {
|
let wb = if wn > 0 {
|
||||||
let w0: String = json_array_get(work_nodes, 0)
|
let w0: String = json_array_get(work_nodes, 0)
|
||||||
let wc0: String = json_get(w0, "content")
|
let wc0: String = json_get(w0, "content")
|
||||||
let wsnip0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 }
|
let ws0: String = if str_len(wc0) > 120 { str_slice(wc0, 0, 120) } else { wc0 }
|
||||||
if str_eq(wsnip0, "") { wbullets } else { "- " + wsnip0 }
|
if str_eq(ws0, "") { wb } else { "- " + ws0 }
|
||||||
} else { wbullets }
|
} else { wb }
|
||||||
let wbullets = if wn > 1 {
|
let wb = if wn > 1 {
|
||||||
let w1: String = json_array_get(work_nodes, 1)
|
let w1: String = json_array_get(work_nodes, 1)
|
||||||
let wc1: String = json_get(w1, "content")
|
let wc1: String = json_get(w1, "content")
|
||||||
let wsnip1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 }
|
let ws1: String = if str_len(wc1) > 120 { str_slice(wc1, 0, 120) } else { wc1 }
|
||||||
if str_eq(wsnip1, "") { wbullets } else { wbullets + "\n- " + wsnip1 }
|
if str_eq(ws1, "") { wb } else { wb + "\n- " + ws1 }
|
||||||
} else { wbullets }
|
} else { wb }
|
||||||
wbullets
|
wb
|
||||||
} else { "" }
|
} else { "" }
|
||||||
|
|
||||||
let has_profile: Bool = !str_eq(profile_bullets, "")
|
let project_bullets: String = if project_ok {
|
||||||
let has_work: Bool = !str_eq(work_bullets, "")
|
let prn: Int = json_array_len(project_nodes)
|
||||||
let preload: String = if has_profile || has_work {
|
let pb: String = ""
|
||||||
let profile_section: String = if has_profile {
|
let pb = if prn > 0 {
|
||||||
"[USER CONTEXT — from memory]\n" + profile_bullets
|
let pr0: String = json_array_get(project_nodes, 0)
|
||||||
} else { "" }
|
let prc0: String = json_get(pr0, "content")
|
||||||
let work_section: String = if has_work {
|
let ps0: String = if str_len(prc0) > 120 { str_slice(prc0, 0, 120) } else { prc0 }
|
||||||
"[ACTIVE WORK — from memory]\n" + work_bullets
|
if str_eq(ps0, "") { pb } else { "- " + ps0 }
|
||||||
} else { "" }
|
} else { pb }
|
||||||
let sep_pw: String = if has_profile && has_work { "\n\n" } else { "" }
|
let pb = if prn > 1 {
|
||||||
"\n\n" + profile_section + sep_pw + work_section
|
let pr1: String = json_array_get(project_nodes, 1)
|
||||||
|
let prc1: String = json_get(pr1, "content")
|
||||||
|
let ps1: String = if str_len(prc1) > 120 { str_slice(prc1, 0, 120) } else { prc1 }
|
||||||
|
if str_eq(ps1, "") { pb } else { pb + "\n- " + ps1 }
|
||||||
|
} else { pb }
|
||||||
|
pb
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
let summary_bullet: String = if summary_ok {
|
||||||
|
let sn0: String = json_array_get(summary_nodes, 0)
|
||||||
|
let sc0: String = json_get(sn0, "content")
|
||||||
|
let ss0: String = if str_len(sc0) > 200 { str_slice(sc0, 0, 200) } else { sc0 }
|
||||||
|
if str_eq(ss0, "") { "" } else { "- " + ss0 }
|
||||||
|
} else { "" }
|
||||||
|
|
||||||
|
let hp: Bool = !str_eq(profile_bullets, "")
|
||||||
|
let hw: Bool = !str_eq(work_bullets, "")
|
||||||
|
let hpr: Bool = !str_eq(project_bullets, "")
|
||||||
|
let hs: Bool = !str_eq(summary_bullet, "")
|
||||||
|
let preload: String = if hp || hw || hpr || hs {
|
||||||
|
let sec_p: String = if hp { "[USER CONTEXT — from memory]\n" + profile_bullets } else { "" }
|
||||||
|
let sec_w: String = if hw { "[ACTIVE WORK — from memory]\n" + work_bullets } else { "" }
|
||||||
|
let sec_pr: String = if hpr { "[PROJECTS — from memory]\n" + project_bullets } else { "" }
|
||||||
|
let sec_s: String = if hs { "[PREVIOUS SESSION — from memory]\n" + summary_bullet } else { "" }
|
||||||
|
let sep1: String = if hp && (hw || hpr || hs) { "\n\n" } else { "" }
|
||||||
|
let sep2: String = if hw && (hpr || hs) { "\n\n" } else { "" }
|
||||||
|
let sep3: String = if hpr && hs { "\n\n" } else { "" }
|
||||||
|
"\n\n" + sec_p + sep1 + sec_w + sep2 + sec_pr + sep3 + sec_s
|
||||||
} else { "" }
|
} else { "" }
|
||||||
preload
|
preload
|
||||||
} else { "" }
|
} else { "" }
|
||||||
@@ -663,13 +833,7 @@ fn handle_chat(body: String) -> String {
|
|||||||
let act_out: String = if act_ok { activation_nodes } else { "[]" }
|
let act_out: String = if act_ok { activation_nodes } else { "[]" }
|
||||||
strengthen_chat_nodes(act_out)
|
strengthen_chat_nodes(act_out)
|
||||||
|
|
||||||
// Q3 fix: surface history load failure in the response envelope so callers can
|
return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + "}"
|
||||||
// show a "starting fresh — could not load previous conversation" indicator.
|
|
||||||
let hist_warning: String = if hist_load_failed {
|
|
||||||
",\"history_load_failed\":true"
|
|
||||||
} else { "" }
|
|
||||||
|
|
||||||
return "{\"response\":\"" + safe_response + "\",\"model\":\"" + model + "\",\"activation_nodes\":" + act_out + hist_warning + "}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_see(body: String) -> String {
|
fn handle_see(body: String) -> String {
|
||||||
@@ -1076,15 +1240,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 {
|
||||||
@@ -1112,7 +1279,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 }
|
||||||
@@ -1138,7 +1305,8 @@ fn handle_chat_agentic(body: String) -> String {
|
|||||||
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
||||||
let agentic_hist: String = state_get(hist_key)
|
let agentic_hist: String = state_get(hist_key)
|
||||||
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
||||||
let ag_is_cont: Bool = str_len(message) < 50 && agentic_hist_len > 0
|
// Issue 8 fix: use engram_is_continuation instead of brittle 50-char threshold.
|
||||||
|
let ag_is_cont: Bool = engram_is_continuation(message, agentic_hist_len)
|
||||||
let ag_last_entry: String = if ag_is_cont { json_array_get(agentic_hist, agentic_hist_len - 1) } else { "" }
|
let ag_last_entry: String = if ag_is_cont { json_array_get(agentic_hist, agentic_hist_len - 1) } else { "" }
|
||||||
let ag_last_content: String = if !str_eq(ag_last_entry, "") { json_get(ag_last_entry, "content") } else { "" }
|
let ag_last_content: String = if !str_eq(ag_last_entry, "") { json_get(ag_last_entry, "content") } else { "" }
|
||||||
let ag_thread_snip: String = if str_len(ag_last_content) > 150 { str_slice(ag_last_content, 0, 150) } else { ag_last_content }
|
let ag_thread_snip: String = if str_len(ag_last_content) > 150 { str_slice(ag_last_content, 0, 150) } else { ag_last_content }
|
||||||
@@ -1648,13 +1816,18 @@ fn auto_persist(req: String, resp: String) -> Void {
|
|||||||
// consistent with what safety_screen already evaluated for this turn.
|
// consistent with what safety_screen already evaluated for this turn.
|
||||||
let bell_level: String = safety_detect_bell_level(message)
|
let bell_level: String = safety_detect_bell_level(message)
|
||||||
let is_bell: Bool = !str_eq(bell_level, "none")
|
let is_bell: Bool = !str_eq(bell_level, "none")
|
||||||
|
let positive_level: String = safety_detect_positive_level(message)
|
||||||
|
let is_positive: Bool = !str_eq(positive_level, "none")
|
||||||
|
|
||||||
// Tag the Conversation node with bell metadata when distress is present so
|
// Tag the Conversation node with affective metadata when emotion is detected.
|
||||||
// subsequent affective queries (e.g. engram_compile) can find this exchange.
|
|
||||||
let tags: String = if is_bell {
|
let tags: String = if is_bell {
|
||||||
"[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]"
|
"[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]"
|
||||||
} else {
|
} else {
|
||||||
"[\"Conversation\",\"chat\",\"timestamped\"]"
|
if is_positive {
|
||||||
|
"[\"Conversation\",\"chat\",\"timestamped\",\"joy:" + positive_level + "\",\"affective\"]"
|
||||||
|
} else {
|
||||||
|
"[\"Conversation\",\"chat\",\"timestamped\"]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let content: String = "{\"q\":\"" + safe_msg + "\""
|
let content: String = "{\"q\":\"" + safe_msg + "\""
|
||||||
@@ -1674,13 +1847,6 @@ fn auto_persist(req: String, resp: String) -> Void {
|
|||||||
"Episodic",
|
"Episodic",
|
||||||
tags
|
tags
|
||||||
)
|
)
|
||||||
// CRITICAL BUG fix: log conv_node_id failure OUTSIDE the is_bell block.
|
|
||||||
// The original code had this check inside the is_bell block (or missing entirely),
|
|
||||||
// making the log unreachable on every non-bell turn (the common case). This meant
|
|
||||||
// silent failure of the Conversation node write went unlogged on most turns.
|
|
||||||
if str_eq(conv_node_id, "") {
|
|
||||||
println("[chat] auto_persist: engram_node_full returned empty — conversation node lost (ts=" + ts_str + ")")
|
|
||||||
}
|
|
||||||
|
|
||||||
// When a bell fires, write a dedicated BellEvent node in addition to the
|
// When a bell fires, write a dedicated BellEvent node in addition to the
|
||||||
// Conversation node. This makes distress moments directly findable by label
|
// Conversation node. This makes distress moments directly findable by label
|
||||||
@@ -1747,6 +1913,28 @@ fn auto_persist(req: String, resp: String) -> Void {
|
|||||||
}
|
}
|
||||||
state_set(signal_key, safe_summary)
|
state_set(signal_key, safe_summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedicated PositiveEvent node for joy/pride/success moments.
|
||||||
|
if is_positive {
|
||||||
|
let pos_summary: String = if str_len(message) > 120 { str_slice(message, 0, 120) } else { message }
|
||||||
|
let safe_pos_sum: String = str_replace(pos_summary, "\"", "'")
|
||||||
|
let pos_content: String = "POSITIVE:" + positive_level
|
||||||
|
+ " | ts:" + ts_str
|
||||||
|
+ " | summary:" + safe_pos_sum
|
||||||
|
let pos_sal_a: String = if str_eq(positive_level, "high") { el_from_float(0.88) } else { el_from_float(0.75) }
|
||||||
|
let pos_sal_b: String = if str_eq(positive_level, "high") { el_from_float(0.88) } else { el_from_float(0.75) }
|
||||||
|
let pos_sal_c: String = if str_eq(positive_level, "high") { el_from_float(0.95) } else { el_from_float(0.85) }
|
||||||
|
let pos_tags: String = "[\"joy\",\"positive\",\"joy:" + positive_level + "\",\"affective\",\"PositiveEvent\"]"
|
||||||
|
let pos_ts_label: String = int_to_str(time_now())
|
||||||
|
let pos_label: String = "joy:" + positive_level + ":" + pos_ts_label
|
||||||
|
let pos_node_id: String = engram_node_full(
|
||||||
|
pos_content, "PositiveEvent", pos_label,
|
||||||
|
pos_sal_a, pos_sal_b, pos_sal_c, "Episodic", pos_tags
|
||||||
|
)
|
||||||
|
if str_eq(pos_node_id, "") {
|
||||||
|
println("[chat] auto_persist: PositiveEvent write failed (ts=" + ts_str + ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// strengthen_chat_nodes — strengthen the engram nodes that were activated during a chat.
|
// strengthen_chat_nodes — strengthen the engram nodes that were activated during a chat.
|
||||||
|
|||||||
+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 ""
|
||||||
}
|
}
|
||||||
@@ -240,13 +244,20 @@ fn safety_general_hard_phrases() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn safety_soft_phrases() -> String {
|
fn safety_soft_phrases() -> String {
|
||||||
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\",\"highest structure\",\"tallest building\",\"tallest structure\",\"highest building\",\"bridge near me\",\"overpass near\",\"rooftop near\"]"
|
return "[\"stressed\",\"overwhelmed\",\"can't cope\",\"cannot cope\",\"struggling\",\"anxious\",\"anxiety\",\"depressed\",\"depression\",\"lonely\",\"isolated\",\"hopeless\",\"hopelessness\",\"exhausted\",\"burnt out\",\"burned out\",\"burnout\",\"panic\",\"panicking\",\"falling apart\",\"breaking down\",\"can't handle\",\"cannot handle\",\"losing it\",\"nothing matters\",\"don't care anymore\",\"given up\",\"giving up\",\"helpless\",\"worthless\",\"useless\",\"hate myself\",\"no one cares\",\"nobody cares\",\"no one understands\",\"nobody understands\",\"empty inside\",\"can't stop crying\",\"breaking point\",\"at my limit\",\"having a breakdown\""]"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) ────────────────────
|
||||||
|
|
||||||
@@ -284,6 +295,26 @@ fn safety_count_match(text: String, phrases_json: String) -> Int {
|
|||||||
|
|
||||||
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
|
// Returns "none" | "soft" | "hard". Hard bell triggers on ANY match (cost of a miss
|
||||||
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
|
// outweighs a false positive). Soft bell needs >= 2 matches to reduce false positives.
|
||||||
|
fn safety_positive_phrases() -> String {
|
||||||
|
return "[\"thrilled\",\"so excited\",\"so happy\",\"over the moon\",\"ecstatic\",\"amazing news\",\"great news\",\"fantastic news\",\"wonderful news\",\"incredible news\",\"i got the job\",\"got accepted\",\"got in\",\"we won\",\"i won\",\"we got\",\"just got engaged\",\"getting married\",\"baby is here\",\"she said yes\",\"he said yes\",\"passed the exam\",\"aced it\",\"nailed it\",\"best day\",\"dream come true\",\"milestone\",\"promotion\",\"got promoted\",\"raise\",\"got a raise\",\"celebrating\",\"just graduated\",\"we closed\",\"launched\",\"shipped it\",\"we did it\",\"so proud\",\"proud of myself\",\"proud of us\",\"so grateful\",\"feel amazing\",\"feeling amazing\",\"feel great\",\"feeling great\",\"on top of the world\",\"life is good\",\"couldn't be happier\"]"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safety_detect_positive_level(message: String) -> String {
|
||||||
|
let phrases: String = safety_positive_phrases()
|
||||||
|
let phrases_ok: Bool = !str_eq(phrases, "") && !str_eq(phrases, "[]")
|
||||||
|
if !phrases_ok { return "none" }
|
||||||
|
let n: Int = json_array_len(phrases)
|
||||||
|
let i: Int = 0
|
||||||
|
while i < n {
|
||||||
|
let phrase: String = json_array_get(phrases, i)
|
||||||
|
if str_contains(message, phrase) {
|
||||||
|
return "high"
|
||||||
|
}
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
fn safety_detect_bell_level(message: String) -> String {
|
fn safety_detect_bell_level(message: String) -> String {
|
||||||
let text: String = safety_normalize(message)
|
let text: String = safety_normalize(message)
|
||||||
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
|
let is_hard: Bool = safety_any_match(text, safety_self_harm_phrases())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -148,14 +148,6 @@ fn load_identity_context() -> Void {
|
|||||||
println("[soul] identity context loaded (" + int_to_str(str_len(ctx)) + " chars, " + int_to_str(parts_count) + " nodes)")
|
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.
|
// 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".
|
// 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.
|
// genesis derives identity from the graph directly; cultivated souls have this node seeded.
|
||||||
@@ -170,11 +162,74 @@ fn load_identity_context() -> Void {
|
|||||||
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
|
println("[soul] persona node loaded (" + int_to_str(str_len(p_content)) + " chars)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Q6 fix: if neither identity nodes nor persona node were loaded, log explicitly.
|
|
||||||
let soul_id_ctx: String = state_get("soul_identity_context")
|
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days.
|
||||||
let soul_persona_ctx: String = state_get("soul_persona")
|
let aff_now: Int = time_now()
|
||||||
if str_eq(soul_id_ctx, "") && str_eq(soul_persona_ctx, "") {
|
let aff_7d: Int = aff_now - 604800
|
||||||
println("[soul] load_identity_context: WARN no identity context available from graph — soul will have identity_block empty in system prompts")
|
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 {
|
||||||
|
let bn_total: Int = json_array_len(bell_raw)
|
||||||
|
let bacc: String = ""
|
||||||
|
let bi: Int = 0
|
||||||
|
let bacc = while bi < bn_total {
|
||||||
|
let bn: String = json_array_get(bell_raw, bi)
|
||||||
|
let bn_c: String = json_get(bn, "content")
|
||||||
|
let bm: String = " | ts:"
|
||||||
|
let bmp: Int = str_index_of(bn_c, bm)
|
||||||
|
let bn_ts_raw: String = 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 { br } else { str_slice(br, 0, bn_next) }
|
||||||
|
} else {
|
||||||
|
let bca: String = json_get(bn, "created_at")
|
||||||
|
if str_eq(bca, "") { json_get(bn, "updated_at") } else { bca }
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
let bacc = if bn_ts >= aff_7d && !str_eq(snip, "") {
|
||||||
|
if str_eq(bacc, "") { snip } else { bacc + "\n" + snip }
|
||||||
|
} else { bacc }
|
||||||
|
let bi = bi + 1
|
||||||
|
bacc
|
||||||
|
}
|
||||||
|
bacc
|
||||||
|
} 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 {
|
||||||
|
let pn_total: Int = json_array_len(pos_raw)
|
||||||
|
let pacc: String = aff_ctx
|
||||||
|
let pi: Int = 0
|
||||||
|
let pacc = while pi < pn_total {
|
||||||
|
let pn: String = json_array_get(pos_raw, pi)
|
||||||
|
let pn_c: String = json_get(pn, "content")
|
||||||
|
let pm: String = " | ts:"
|
||||||
|
let pmp: Int = str_index_of(pn_c, pm)
|
||||||
|
let pn_ts_raw: String = if pmp >= 0 {
|
||||||
|
let ps: Int = pmp + str_len(pm)
|
||||||
|
let pr: String = str_slice(pn_c, ps, str_len(pn_c))
|
||||||
|
let pn_next: Int = str_index_of(pr, " | ")
|
||||||
|
if pn_next < 0 { pr } else { str_slice(pr, 0, pn_next) }
|
||||||
|
} else {
|
||||||
|
let pca: String = json_get(pn, "created_at")
|
||||||
|
if str_eq(pca, "") { json_get(pn, "updated_at") } else { pca }
|
||||||
|
}
|
||||||
|
let pn_ts: Int = if str_eq(pn_ts_raw, "") { 0 } else { str_to_int(pn_ts_raw) }
|
||||||
|
let psnip: String = if str_len(pn_c) > 200 { str_slice(pn_c, 0, 200) } else { pn_c }
|
||||||
|
let pacc = if pn_ts >= aff_7d && !str_eq(psnip, "") {
|
||||||
|
if str_eq(pacc, "") { psnip } else { pacc + "\n" + psnip }
|
||||||
|
} else { pacc }
|
||||||
|
let pi = pi + 1
|
||||||
|
pacc
|
||||||
|
}
|
||||||
|
pacc
|
||||||
|
} 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,8 +365,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") {
|
||||||
@@ -334,23 +392,53 @@ fn layered_cycle(raw_input: String) -> String {
|
|||||||
json_get(steward_result, "redirect_to")
|
json_get(steward_result, "redirect_to")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISSUE 1: pre-LLM bell augmentation for layered_cycle path.
|
// L2c: affective context injection.
|
||||||
// safety_augment_system appends soft/hard directive to system prompt when bell fires,
|
let lc_aff_cutoff: Int = time_now() - 259200
|
||||||
// ensuring LLM processes message WITH the safety directive -- not just post-output gate.
|
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
|
||||||
// Stored in state as "layered_cycle_safety_system_addendum" for imprint_respond to use.
|
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
|
||||||
// TODO: wire directly when imprint_respond gains system_override param (imprint.el change).
|
let lc_bell_note: String = if lc_has_bell {
|
||||||
// ISSUE 3 TODO: no semantic crisis detection. Keyword-only means signals that evade
|
let lb0: String = json_array_get(lc_bell_nodes, 0)
|
||||||
// the phrase list pass with zero augmentation. Semantic layer = separate decision.
|
let lb_c: String = json_get(lb0, "content")
|
||||||
//
|
let lbm: String = " | ts:"
|
||||||
// Q8 race documentation: "layered_cycle_safety_system_addendum" is a shared process-global
|
let lbmp: Int = str_index_of(lb_c, lbm)
|
||||||
// state key. Two concurrent requests to layered_cycle() both write this key; whichever
|
let lb_ts_raw: String = if lbmp >= 0 {
|
||||||
// writes last wins. The concurrent build_system_prompt() read in chat.el:236 may then
|
let lbs: Int = lbmp + str_len(lbm)
|
||||||
// consume the wrong request's addendum, or find an empty string after the other request's
|
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
|
||||||
// build_system_prompt consumed and cleared it. Mitigation: under http_serve_async, the
|
let lbn: Int = str_index_of(lbr, " | ")
|
||||||
// layered_cycle path and the /api/chat path are different endpoints (typically); true
|
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
|
||||||
// concurrent layered_cycle calls are uncommon. A robust fix requires per-request state
|
} else {
|
||||||
// scoping which needs C runtime support (e.g. a request-id-keyed addendum map).
|
let lbca: String = json_get(lb0, "created_at")
|
||||||
|
if str_eq(lbca, "") { json_get(lb0, "updated_at") } else { lbca }
|
||||||
|
}
|
||||||
|
let lb_ts: Int = if str_eq(lb_ts_raw, "") { 0 } else { str_to_int(lb_ts_raw) }
|
||||||
|
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, "[]")
|
||||||
|
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_c: String = json_get(lp0, "content")
|
||||||
|
let lpm: String = " | ts:"
|
||||||
|
let lpmp: Int = str_index_of(lp_c, lpm)
|
||||||
|
let lp_ts_raw: String = if lpmp >= 0 {
|
||||||
|
let lps: Int = lpmp + str_len(lpm)
|
||||||
|
let lpr: String = str_slice(lp_c, lps, str_len(lp_c))
|
||||||
|
let lpn: Int = str_index_of(lpr, " | ")
|
||||||
|
if lpn < 0 { lpr } else { str_slice(lpr, 0, lpn) }
|
||||||
|
} else {
|
||||||
|
let lpca: String = json_get(lp0, "created_at")
|
||||||
|
if str_eq(lpca, "") { json_get(lp0, "updated_at") } else { lpca }
|
||||||
|
}
|
||||||
|
let lp_ts: Int = if str_eq(lp_ts_raw, "") { 0 } else { str_to_int(lp_ts_raw) }
|
||||||
|
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: 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)
|
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
|
||||||
|
|
||||||
// L3: imprint responds
|
// L3: imprint responds
|
||||||
|
|||||||
Reference in New Issue
Block a user