Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42bbadcd33 | |||
| b6052f9de3 | |||
| 0113407728 | |||
| 18e040acb1 | |||
| 21f248a33a | |||
| aef687b57c | |||
| 6edf9937dd | |||
| e447a87a00 | |||
| 575ff1329a | |||
| db33b0cb91 | |||
| f35569d4bb | |||
| 94b71b6e6b | |||
| 392d2416ec | |||
| 87c7d15b67 | |||
| 93bed793c0 | |||
| 936b3f0ac9 | |||
| 45dc80230d | |||
| 9ba86b8f80 | |||
| 360c15d7fe | |||
| 0c5b966773 | |||
| c87a536da3 | |||
| 2865d6ad26 | |||
| 47d0e6f985 | |||
| f0545defdb | |||
| ae9a139440 | |||
| d008649c3e | |||
| aa70c5dde6 | |||
| b7fd8901d4 | |||
| deddb9a18e | |||
| 494d973a3b | |||
| 34551695a1 | |||
| dcf050ee3c | |||
| 615f0cee08 | |||
| b2008f4894 | |||
| d92b8c279a | |||
| e9a8a659e0 | |||
| 1b83b18c39 |
@@ -73,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)
|
||||||
@@ -113,56 +114,322 @@ 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_split_topics — split message into sub-queries on explicit conjunctions.
|
||||||
|
// "health goals AND startup progress" becomes two independent searches.
|
||||||
|
fn engram_split_topics(message: String) -> String {
|
||||||
|
let sep: String = if str_contains(message, " AND ") { " AND " } else {
|
||||||
|
if str_contains(message, " and ") { " and " } else {
|
||||||
|
if str_contains(message, " also ") { " also " } else {
|
||||||
|
if str_contains(message, " plus ") { " plus " } else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// engram_extract_entities — extract probable named entities (capital-first, 3+ chars,
|
||||||
|
// not stop-words) from a message. Returns newline-separated list.
|
||||||
|
fn engram_extract_entities(message: String) -> String {
|
||||||
|
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|"
|
||||||
|
let capitals: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
let entities: String = ""
|
||||||
|
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 {
|
fn engram_compile(intent: String) -> String {
|
||||||
let activate_json: String = engram_activate_json(intent, 5)
|
// Issue 1: decompose multi-topic messages into sub-queries.
|
||||||
// Fetch more search results than we'll use so ranking has a real pool to pick from.
|
let topics: String = engram_split_topics(intent)
|
||||||
let search_json: String = engram_search_json(intent, 20)
|
let has_multi_topic: Bool = str_contains(topics, "\n")
|
||||||
|
|
||||||
let act_ok: Bool = !str_eq(activate_json, "") && !str_eq(activate_json, "[]")
|
// Issue 4: detect explicit recall intent and run boosted search.
|
||||||
let srch_ok: Bool = !str_eq(search_json, "") && !str_eq(search_json, "[]")
|
let is_recall_intent: Bool = engram_detect_recall_intent(intent)
|
||||||
|
|
||||||
// Activation nodes (spreading activation) are already high-signal — keep all 5.
|
// Issue 2: extract named entities for dedicated per-entity searches.
|
||||||
let act_part: String = if act_ok { activate_json } else { "" }
|
let entity_list: String = engram_extract_entities(intent)
|
||||||
|
let has_entities: Bool = !str_eq(entity_list, "")
|
||||||
|
|
||||||
// Rank search results and keep only the top 8 (was: flat 15 unranked).
|
// Primary topic search (first or only topic).
|
||||||
// This cuts context noise roughly in half while preserving the best-scoring nodes.
|
let topic0: String = if has_multi_topic {
|
||||||
let srch_ranked: String = if srch_ok { engram_compile_ranked(search_json, 8) } else { "" }
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
let srch_part: String = srch_ranked
|
str_slice(topics, 0, nl0)
|
||||||
|
} else { topics }
|
||||||
|
let nodes0: String = engram_compile_multi(topic0)
|
||||||
|
|
||||||
// Fallback: when vector search returns nothing (no embeddings), fetch pinned
|
// Second topic segment.
|
||||||
// high-salience nodes by their known IDs. These are the canonical identity
|
let nodes1: String = if has_multi_topic {
|
||||||
// and biography nodes that should always be in context.
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
// engram_get_node_json(id) returns a single node as JSON or "" if missing.
|
let rest1: String = str_slice(topics, nl0 + 1, str_len(topics))
|
||||||
let scan_part: String = if !act_ok && !srch_ok {
|
let nl1: Int = str_index_of(rest1, "\n")
|
||||||
let family_node: String = engram_get_node_json("knw-35940684-abc4-42f0-b942-818f66b1f69a")
|
let topic1: String = if nl1 < 0 { rest1 } else { str_slice(rest1, 0, nl1) }
|
||||||
let origin_node: String = engram_get_node_json("knw-729fc901-8335-44c4-9f3a-b150b4aa0915")
|
if str_eq(topic1, "") { "" } else { engram_compile_multi(topic1) }
|
||||||
let fam_ok: Bool = !str_eq(family_node, "") && !str_eq(family_node, "null")
|
} else { "" }
|
||||||
let orig_ok: Bool = !str_eq(origin_node, "") && !str_eq(origin_node, "null")
|
|
||||||
let fam_str: String = if fam_ok { family_node } else { "" }
|
// Third topic segment.
|
||||||
let orig_str: String = if orig_ok { origin_node } else { "" }
|
let nodes2: String = if has_multi_topic {
|
||||||
let sep: String = if fam_ok && orig_ok { "\n" } else { "" }
|
let nl0: Int = str_index_of(topics, "\n")
|
||||||
let combined: String = fam_str + sep + orig_str
|
let rest1: String = str_slice(topics, nl0 + 1, str_len(topics))
|
||||||
if str_eq(combined, "") { "" } else { combined }
|
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_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
|
||||||
|
let now_ts: Int = time_now()
|
||||||
|
let cutoff_ts: Int = now_ts - 259200
|
||||||
|
let recent_bell: String = if bell_ok {
|
||||||
|
let bn0: String = json_array_get(bell_nodes, 0)
|
||||||
|
let bn_content: String = json_get(bn0, "content")
|
||||||
|
let ts_marker: String = " | ts:"
|
||||||
|
let ts_pos: Int = str_index_of(bn_content, ts_marker)
|
||||||
|
let bn_ts_raw: String = if ts_pos >= 0 {
|
||||||
|
let ts_start: Int = ts_pos + str_len(ts_marker)
|
||||||
|
let rest: String = str_slice(bn_content, ts_start, str_len(bn_content))
|
||||||
|
let next_sep: Int = str_index_of(rest, " | ")
|
||||||
|
if next_sep < 0 { rest } else { str_slice(rest, 0, next_sep) }
|
||||||
|
} else {
|
||||||
|
let ca: String = json_get(bn0, "created_at")
|
||||||
|
if str_eq(ca, "") { json_get(bn0, "updated_at") } else { ca }
|
||||||
|
}
|
||||||
|
let bn_ts: Int = if str_eq(bn_ts_raw, "") { 0 } else { str_to_int(bn_ts_raw) }
|
||||||
|
if bn_ts > cutoff_ts { bn0 } else { "" }
|
||||||
|
} else { "" }
|
||||||
|
// Positive emotion context: check for recent joy/success moments within 72h.
|
||||||
|
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 recent_positive_ec: String = if pos_ec_ok {
|
||||||
|
let pec0: String = json_array_get(pos_ec_nodes, 0)
|
||||||
|
let pec_content: String = json_get(pec0, "content")
|
||||||
|
let pec_ts_marker: String = " | ts:"
|
||||||
|
let pec_ts_pos: Int = str_index_of(pec_content, pec_ts_marker)
|
||||||
|
let pec_ts_raw: String = if pec_ts_pos >= 0 {
|
||||||
|
let pec_ts_start: Int = pec_ts_pos + str_len(pec_ts_marker)
|
||||||
|
let pec_rest: String = str_slice(pec_content, pec_ts_start, str_len(pec_content))
|
||||||
|
let pec_next: Int = str_index_of(pec_rest, " | ")
|
||||||
|
if pec_next < 0 { pec_rest } else { str_slice(pec_rest, 0, pec_next) }
|
||||||
|
} else {
|
||||||
|
let pec_ca: String = json_get(pec0, "created_at")
|
||||||
|
if str_eq(pec_ca, "") { json_get(pec0, "updated_at") } else { pec_ca }
|
||||||
|
}
|
||||||
|
let pec_ts: Int = if str_eq(pec_ts_raw, "") { 0 } else { str_to_int(pec_ts_raw) }
|
||||||
|
if pec_ts > cutoff_ts { pec0 } else { "" }
|
||||||
|
} else { "" }
|
||||||
|
let affective_part: String = if !str_eq(recent_bell, "") {
|
||||||
|
recent_bell
|
||||||
} else {
|
} else {
|
||||||
""
|
if !str_eq(recent_positive_ec, "") { recent_positive_ec } else { "" }
|
||||||
}
|
}
|
||||||
|
|
||||||
let sep1: String = if !str_eq(act_part, "") && !str_eq(srch_part, "") { "\n" } else { "" }
|
let has_main: Bool = !str_eq(merged_nodes, "") && !str_eq(merged_nodes, "[]")
|
||||||
let sep2: String = if (!str_eq(act_part, "") || !str_eq(srch_part, "")) && !str_eq(scan_part, "") { "\n" } else { "" }
|
let main_part: String = if has_main { merged_nodes } else { scan_part }
|
||||||
let ctx: String = act_part + sep1 + srch_part + sep2 + scan_part
|
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 "" }
|
if str_eq(ctx, "") { return "" }
|
||||||
|
|
||||||
// Raise the cap slightly to match the ranked (higher-signal) output.
|
// Issue 7 fix: safe JSON truncation — find last closing brace before budget cap.
|
||||||
if str_len(ctx) > 6000 {
|
// Budget raised from 6000 to 8000 for the larger multi-topic pool.
|
||||||
return str_slice(ctx, 0, 6000)
|
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, "\"", "\\\"")
|
||||||
@@ -199,7 +466,15 @@ fn build_system_prompt(ctx: String) -> String {
|
|||||||
"\n\n[ENGRAM CONTEXT — compiled from your graph]\n" + ctx
|
"\n\n[ENGRAM CONTEXT — compiled from your graph]\n" + ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
return identity + date_line + voice_rules + security_rules + capability_rules + identity_block + engram_block
|
let safety_addendum: String = state_get("layered_cycle_safety_system_addendum")
|
||||||
|
let safety_block: String = if str_eq(safety_addendum, "") {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
state_set("layered_cycle_safety_system_addendum", "")
|
||||||
|
safety_addendum
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -226,6 +501,69 @@ fn hist_trim(hist: String) -> String {
|
|||||||
return hist
|
return hist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hist_trim_with_bell_guard — trim the history window exactly as hist_trim does, but
|
||||||
|
// before dropping the oldest user/assistant pair check whether the user turn triggered
|
||||||
|
// a bell event. If it did, write a preservation node to engram so the distress exchange
|
||||||
|
// survives the 20-turn window. The LLM window drops it; engram retains it permanently
|
||||||
|
// and engram_compile will surface it again via the affective context path.
|
||||||
|
fn hist_trim_with_bell_guard(hist: String) -> String {
|
||||||
|
// Extract the first turn (should be a user message) to inspect it.
|
||||||
|
let inner: String = str_slice(hist, 1, str_len(hist) - 1)
|
||||||
|
let marker: String = "{\"role\":"
|
||||||
|
let i1: Int = str_index_of(inner, marker)
|
||||||
|
// i1 is the start of the first entry within inner.
|
||||||
|
// Find where the second entry begins to delimit the first entry's JSON.
|
||||||
|
let tail1: String = str_slice(inner, i1 + 1, str_len(inner))
|
||||||
|
let i2: Int = str_index_of(tail1, marker)
|
||||||
|
// The first entry spans from i1 to (i1 + 1 + i2 - 1) within inner.
|
||||||
|
let first_entry_raw: String = if i2 > 0 {
|
||||||
|
str_slice(inner, i1, i1 + 1 + i2 - 1)
|
||||||
|
} else {
|
||||||
|
str_slice(inner, i1, str_len(inner))
|
||||||
|
}
|
||||||
|
let first_role: String = json_get(first_entry_raw, "role")
|
||||||
|
let first_content: String = json_get(first_entry_raw, "content")
|
||||||
|
|
||||||
|
// Only inspect user turns — assistant content doesn't carry bell signals.
|
||||||
|
let bell_level: String = if str_eq(first_role, "user") {
|
||||||
|
safety_detect_bell_level(first_content)
|
||||||
|
} else {
|
||||||
|
"none"
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the turn being evicted triggered a bell, preserve it to engram.
|
||||||
|
// This is distinct from the BellEvent written by auto_persist: that node
|
||||||
|
// carries a short summary. This node carries the full exchange content so
|
||||||
|
// it is recoverable for clinical/continuity review.
|
||||||
|
if !str_eq(bell_level, "none") {
|
||||||
|
let ts: Int = time_now()
|
||||||
|
let ts_str: String = int_to_str(ts)
|
||||||
|
let safe_content: String = str_replace(first_content, "\"", "'")
|
||||||
|
let preserve_content: String = "PRESERVED_BELL:" + bell_level
|
||||||
|
+ " | evicted_at:" + ts_str
|
||||||
|
+ " | message:" + safe_content
|
||||||
|
let preserve_tags: String = "[\"bell-history\",\"bell:" + bell_level + "\",\"evicted\",\"affective\",\"BellEvent\"]"
|
||||||
|
let discard: String = engram_node_full(
|
||||||
|
preserve_content,
|
||||||
|
"BellEvent",
|
||||||
|
"bell:" + bell_level + ":preserved",
|
||||||
|
el_from_float(0.9),
|
||||||
|
el_from_float(0.9),
|
||||||
|
el_from_float(1.0),
|
||||||
|
"Episodic",
|
||||||
|
preserve_tags
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now perform the standard trim (drop oldest 2 entries = 1 user + 1 assistant pair).
|
||||||
|
let tail2: String = str_slice(tail1, i2 + 1, str_len(tail1))
|
||||||
|
let i3: Int = str_index_of(tail2, marker)
|
||||||
|
if i3 >= 0 {
|
||||||
|
return "[" + str_slice(tail2, i3, str_len(tail2)) + "]"
|
||||||
|
}
|
||||||
|
return hist
|
||||||
|
}
|
||||||
|
|
||||||
// clean_llm_response — strips GPT-2 BPE byte-to-unicode artifacts that vLLM
|
// clean_llm_response — strips GPT-2 BPE byte-to-unicode artifacts that vLLM
|
||||||
// emits when the tokenizer hasn't decoded back to raw bytes.
|
// emits when the tokenizer hasn't decoded back to raw bytes.
|
||||||
//
|
//
|
||||||
@@ -281,88 +619,174 @@ fn handle_chat(body: String) -> String {
|
|||||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||||
|
|
||||||
// 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 {
|
||||||
message
|
message
|
||||||
}
|
}
|
||||||
|
|
||||||
let ctx: String = engram_compile(activation_seed)
|
// Cross-session affective context: on session start (no history yet), check engram
|
||||||
let system: String = build_system_prompt(ctx)
|
// for recent distress signals within 72h and prepend a care directive if found.
|
||||||
|
let affective_prefix: String = {
|
||||||
|
// Runs every turn. Uses correct BellEvent/PositiveEvent tags.
|
||||||
|
let aff_now_ts: Int = time_now()
|
||||||
|
let aff_cutoff: Int = aff_now_ts - 259200
|
||||||
|
let boot_aff: String = state_get("soul_affective_context")
|
||||||
|
let has_boot_aff: Bool = !str_eq(boot_aff, "")
|
||||||
|
let dist_nodes_aff: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||||
|
let has_dist_aff: Bool = !str_eq(dist_nodes_aff, "") && !str_eq(dist_nodes_aff, "[]")
|
||||||
|
let found_recent_dist: Bool = if has_boot_aff {
|
||||||
|
true
|
||||||
|
} 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 }
|
||||||
|
if found_recent_dist {
|
||||||
|
"[RECENT CONTEXT: User recently expressed significant distress. Monitor for indirect crisis signals and respond with care.]\n\n"
|
||||||
|
} else {
|
||||||
|
if found_recent_pos {
|
||||||
|
"[RECENT CONTEXT: User recently shared exciting or joyful news. Acknowledge and celebrate with them when relevant.]\n\n"
|
||||||
|
} else { "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// First message of the session: proactively load user profile and active work context.
|
let ctx: String = engram_compile(activation_seed)
|
||||||
// These two searches give the soul grounding before any conversation history exists.
|
let system: String = affective_prefix + build_system_prompt(ctx)
|
||||||
// Results are rendered as brief bullets — not raw JSON — so they don't inflate context.
|
|
||||||
|
// Issue 9 fix: add project-specific and session-summary searches to session preload.
|
||||||
|
// Old hardcoded "user profile" and "in_progress active project" miss project-specific
|
||||||
|
// 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 { "" }
|
||||||
@@ -376,6 +800,10 @@ fn handle_chat(body: String) -> String {
|
|||||||
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 }
|
||||||
|
|
||||||
|
// ISSUE 9: add safety_augment_system to primary /api/chat path.
|
||||||
|
// handle_chat was the only LLM path missing bell directive injection.
|
||||||
|
let full_system = safety_augment_system(full_system, message)
|
||||||
|
|
||||||
let raw_response: String = llm_call_system(model, full_system, message)
|
let raw_response: String = llm_call_system(model, full_system, message)
|
||||||
|
|
||||||
let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|
let is_error: Bool = str_starts_with(raw_response, "{\"error\"")
|
||||||
@@ -390,8 +818,10 @@ fn handle_chat(body: String) -> String {
|
|||||||
|
|
||||||
let updated_hist: String = hist_append(stored_hist, "user", message)
|
let updated_hist: String = hist_append(stored_hist, "user", message)
|
||||||
let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response)
|
let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response)
|
||||||
|
// Use bell-guarded trim: if the evicted turn triggered a bell event, it is
|
||||||
|
// preserved to engram before being dropped from the in-memory window.
|
||||||
let final_hist: String = if json_array_len(updated_hist2) > 20 {
|
let final_hist: String = if json_array_len(updated_hist2) > 20 {
|
||||||
hist_trim(updated_hist2)
|
hist_trim_with_bell_guard(updated_hist2)
|
||||||
} else {
|
} else {
|
||||||
updated_hist2
|
updated_hist2
|
||||||
}
|
}
|
||||||
@@ -608,7 +1038,8 @@ fn path_within_root(path: String, root: String) -> Bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if str_starts_with(path, "/") {
|
if str_starts_with(path, "/") {
|
||||||
return str_starts_with(path, root)
|
let root_normalized: String = root + "/"
|
||||||
|
return str_starts_with(path, root_normalized)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -699,12 +1130,17 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
|||||||
let path: String = json_get(tool_input, "path")
|
let path: String = json_get(tool_input, "path")
|
||||||
let old_text: String = json_get(tool_input, "old_text")
|
let old_text: String = json_get(tool_input, "old_text")
|
||||||
let new_text: String = json_get(tool_input, "new_text")
|
let new_text: String = json_get(tool_input, "new_text")
|
||||||
let content: String = fs_read(path)
|
let root: String = agent_workspace_root()
|
||||||
|
if !path_within_root(path, root) {
|
||||||
|
return json_safe("denied: path is outside the agent workspace root")
|
||||||
|
}
|
||||||
|
let resolved: String = resolve_in_root(path, root)
|
||||||
|
let content: String = fs_read(resolved)
|
||||||
if str_eq(content, "") {
|
if str_eq(content, "") {
|
||||||
return json_safe("{\"error\":\"file not found\"}")
|
return json_safe("{\"error\":\"file not found\"}")
|
||||||
}
|
}
|
||||||
let updated: String = str_replace(content, old_text, new_text)
|
let updated: String = str_replace(content, old_text, new_text)
|
||||||
fs_write(path, updated)
|
fs_write(resolved, updated)
|
||||||
return json_safe("{\"ok\":true}")
|
return json_safe("{\"ok\":true}")
|
||||||
}
|
}
|
||||||
if str_eq(tool_name, "remember") {
|
if str_eq(tool_name, "remember") {
|
||||||
@@ -824,6 +1260,17 @@ fn handle_chat_agentic(body: String) -> String {
|
|||||||
return "{\"error\":\"message required\",\"reply\":\"\"}"
|
return "{\"error\":\"message required\",\"reply\":\"\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workspace scope (#23): the desktop UI sends the user-chosen Agent Workspace root
|
||||||
|
// on every agentic request. Persist it to state so agent_workspace_root() — and the
|
||||||
|
// path/command tool guards that read it — confine this turn's file/command tools to
|
||||||
|
// that subtree. Only set when non-empty: an empty/absent field means the client sent
|
||||||
|
// no root (or cleared the field), and we must not overwrite a server-configured root
|
||||||
|
// from NEURON_AGENT_ROOT with an empty string, which would silently un-scope the agent.
|
||||||
|
let ws_root: String = json_get(body, "agent_workspace_root")
|
||||||
|
if !str_eq(ws_root, "") {
|
||||||
|
state_set("agent_workspace_root", ws_root)
|
||||||
|
}
|
||||||
|
|
||||||
// L1 safety screen — agentic path must pass the same gate as layered_cycle.
|
// L1 safety screen — agentic path must pass the same gate as layered_cycle.
|
||||||
// Hard bell: return the crisis response immediately, do not enter the agentic loop.
|
// Hard bell: return the crisis response immediately, do not enter the agentic loop.
|
||||||
let history: String = state_get("conversation_history")
|
let history: String = state_get("conversation_history")
|
||||||
@@ -832,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 }
|
||||||
@@ -840,10 +1287,26 @@ fn handle_chat_agentic(body: String) -> String {
|
|||||||
// Thread-aware activation: same logic as handle_chat.
|
// Thread-aware activation: same logic as handle_chat.
|
||||||
// Use the session's or global history to anchor short messages to the thread.
|
// Use the session's or global history to anchor short messages to the thread.
|
||||||
let req_session: String = json_get(body, "session_id")
|
let req_session: String = json_get(body, "session_id")
|
||||||
|
|
||||||
|
// ISSUE #6/#7: validate that the session_id actually exists before proceeding.
|
||||||
|
// Without this check the loop silently treats any unknown/fabricated session_id
|
||||||
|
// as a fresh session — history loads as empty and no error is returned to the caller.
|
||||||
|
// Only validate when a session_id is explicitly provided; anonymous calls
|
||||||
|
// (no session_id) continue to work for backward compatibility.
|
||||||
|
let session_valid: Bool = if str_eq(req_session, "") {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
session_exists(req_session)
|
||||||
|
}
|
||||||
|
if !session_valid {
|
||||||
|
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
|
||||||
|
}
|
||||||
|
|
||||||
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 }
|
||||||
@@ -1348,14 +1811,33 @@ fn auto_persist(req: String, resp: String) -> Void {
|
|||||||
let safe_msg: String = str_replace(message, "\"", "'")
|
let safe_msg: String = str_replace(message, "\"", "'")
|
||||||
let safe_reply: String = str_replace(reply2, "\"", "'")
|
let safe_reply: String = str_replace(reply2, "\"", "'")
|
||||||
|
|
||||||
|
// Detect emotional salience before persisting. safety_detect_bell_level uses the
|
||||||
|
// same phrase lists as the safety layer (safety.el), so the classification is
|
||||||
|
// consistent with what safety_screen already evaluated for this turn.
|
||||||
|
let bell_level: String = safety_detect_bell_level(message)
|
||||||
|
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 affective metadata when emotion is detected.
|
||||||
|
let tags: String = if is_bell {
|
||||||
|
"[\"Conversation\",\"chat\",\"timestamped\",\"bell:" + bell_level + "\",\"affective\"]"
|
||||||
|
} else {
|
||||||
|
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 + "\""
|
||||||
+ ",\"a\":\"" + safe_reply + "\""
|
+ ",\"a\":\"" + safe_reply + "\""
|
||||||
+ ",\"created_at\":" + ts_str
|
+ ",\"created_at\":" + ts_str
|
||||||
+ ",\"source\":\"chat\""
|
+ ",\"source\":\"chat\""
|
||||||
|
+ ",\"bell\":\"" + bell_level + "\""
|
||||||
+ ",\"label\":\"chat:" + ts_str + "\"}"
|
+ ",\"label\":\"chat:" + ts_str + "\"}"
|
||||||
|
|
||||||
let tags: String = "[\"Conversation\",\"chat\",\"timestamped\"]"
|
let conv_node_id: String = engram_node_full(
|
||||||
engram_node_full(
|
|
||||||
content,
|
content,
|
||||||
"Conversation",
|
"Conversation",
|
||||||
"chat:" + ts_str,
|
"chat:" + ts_str,
|
||||||
@@ -1365,6 +1847,94 @@ fn auto_persist(req: String, resp: String) -> Void {
|
|||||||
"Episodic",
|
"Episodic",
|
||||||
tags
|
tags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// When a bell fires, write a dedicated BellEvent node in addition to the
|
||||||
|
// Conversation node. This makes distress moments directly findable by label
|
||||||
|
// ("bell:soft" / "bell:hard") without having to scan all Conversation nodes.
|
||||||
|
// The BellEvent carries higher salience so engram_compile pulls it into context.
|
||||||
|
// The message content is truncated to 120 chars — enough signal, not a full dump.
|
||||||
|
if is_bell {
|
||||||
|
let summary: String = if str_len(message) > 120 { str_slice(message, 0, 120) } else { message }
|
||||||
|
let safe_summary: String = str_replace(summary, "\"", "'")
|
||||||
|
let bell_content: String = "BELL:" + bell_level
|
||||||
|
+ " | ts:" + ts_str
|
||||||
|
+ " | summary:" + safe_summary
|
||||||
|
|
||||||
|
// bell:hard gets peak salience; bell:soft is slightly lower.
|
||||||
|
let sal_a: String = if str_eq(bell_level, "hard") { el_from_float(0.98) } else { el_from_float(0.88) }
|
||||||
|
let sal_b: String = if str_eq(bell_level, "hard") { el_from_float(0.98) } else { el_from_float(0.88) }
|
||||||
|
let sal_c: String = if str_eq(bell_level, "hard") { el_from_float(1.0) } else { el_from_float(0.95) }
|
||||||
|
|
||||||
|
let bell_tags: String = "[\"safety\",\"bell\",\"bell:" + bell_level + "\",\"affective\",\"BellEvent\"]"
|
||||||
|
let bell_ts_str: String = int_to_str(time_now())
|
||||||
|
let bell_label: String = "bell:" + bell_level + ":" + bell_ts_str
|
||||||
|
let bell_node_id: String = engram_node_full(
|
||||||
|
bell_content,
|
||||||
|
"BellEvent",
|
||||||
|
bell_label,
|
||||||
|
sal_a,
|
||||||
|
sal_b,
|
||||||
|
sal_c,
|
||||||
|
"Episodic",
|
||||||
|
bell_tags
|
||||||
|
)
|
||||||
|
|
||||||
|
// Increment session-level bell counter so session_hist_save knows whether
|
||||||
|
// any bell fired during this session when writing a boundary summary.
|
||||||
|
let sess_id: String = json_get(req, "session_id")
|
||||||
|
let bell_key: String = if str_eq(sess_id, "") {
|
||||||
|
"session_bell_count"
|
||||||
|
} else {
|
||||||
|
"session_bell_count:" + sess_id
|
||||||
|
}
|
||||||
|
let prior_count: String = state_get(bell_key)
|
||||||
|
let prior_n: Int = if str_eq(prior_count, "") { 0 } else { str_to_int(prior_count) }
|
||||||
|
state_set(bell_key, int_to_str(prior_n + 1))
|
||||||
|
|
||||||
|
// Also record the highest bell level seen this session so the boundary
|
||||||
|
// summary can classify the session correctly (hard takes precedence).
|
||||||
|
let level_key: String = if str_eq(sess_id, "") {
|
||||||
|
"session_bell_level"
|
||||||
|
} else {
|
||||||
|
"session_bell_level:" + sess_id
|
||||||
|
}
|
||||||
|
let prior_level: String = state_get(level_key)
|
||||||
|
let new_level: String = if str_eq(bell_level, "hard") { "hard" } else {
|
||||||
|
if str_eq(prior_level, "hard") { "hard" } else { "soft" }
|
||||||
|
}
|
||||||
|
state_set(level_key, new_level)
|
||||||
|
|
||||||
|
// Stash a short signal summary for the boundary node (last bell wins for
|
||||||
|
// the one-liner; the full history is in per-bell BellEvent nodes).
|
||||||
|
let signal_key: String = if str_eq(sess_id, "") {
|
||||||
|
"session_bell_signal"
|
||||||
|
} else {
|
||||||
|
"session_bell_signal:" + sess_id
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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 + "\"}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,65 @@ import "neuron-api.el"
|
|||||||
import "sessions.el"
|
import "sessions.el"
|
||||||
import "soul.elh"
|
import "soul.elh"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Rate limiting — simple in-memory per-IP sliding window counter.
|
||||||
|
//
|
||||||
|
// State keys:
|
||||||
|
// rl:<ip>:count — request count in the current window
|
||||||
|
// rl:<ip>:window — window start timestamp (unix seconds)
|
||||||
|
//
|
||||||
|
// Limit: configurable via soul state key "soul_rate_limit" (requests per
|
||||||
|
// minute). Falls back to 60 req/min if not set. The /health endpoint is
|
||||||
|
// exempt so monitoring does not consume quota.
|
||||||
|
//
|
||||||
|
// State growth: each unique source IP accumulates exactly 2 state keys
|
||||||
|
// (count + window) for the lifetime of the process. Per-IP storage is
|
||||||
|
// bounded and constant; values reset on window expiry. In aggregate, state
|
||||||
|
// grows linearly with distinct IPs — typical for a trusted-client service.
|
||||||
|
// EL has no state_delete builtin, so keys from inactive IPs persist.
|
||||||
|
// TODO: add state_delete sweep when the EL runtime exposes that primitive.
|
||||||
|
//
|
||||||
|
// Returns "" when the request is allowed, or a 429 JSON body when rejected.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
fn rate_limit_check(ip: String, path: String) -> String {
|
||||||
|
// Health checks are exempt — they must never be blocked.
|
||||||
|
if str_eq(path, "/health") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit_str: String = state_get("soul_rate_limit")
|
||||||
|
let limit: Int = if str_eq(limit_str, "") { 60 } else { str_to_int(limit_str) }
|
||||||
|
|
||||||
|
let now: Int = time_now()
|
||||||
|
let window_key: String = "rl:" + ip + ":window"
|
||||||
|
let count_key: String = "rl:" + ip + ":count"
|
||||||
|
|
||||||
|
let win_str: String = state_get(window_key)
|
||||||
|
let win_start: Int = if str_eq(win_str, "") { now } else { str_to_int(win_str) }
|
||||||
|
|
||||||
|
// New window every 60 seconds.
|
||||||
|
let elapsed: Int = now - win_start
|
||||||
|
let in_window: Bool = elapsed < 60
|
||||||
|
|
||||||
|
let prev_count_str: String = state_get(count_key)
|
||||||
|
let prev_count: Int = if str_eq(prev_count_str, "") { 0 } else { str_to_int(prev_count_str) }
|
||||||
|
|
||||||
|
// Reset window if expired.
|
||||||
|
let eff_count: Int = if in_window { prev_count } else { 0 }
|
||||||
|
let eff_win: Int = if in_window { win_start } else { now }
|
||||||
|
|
||||||
|
let new_count: Int = eff_count + 1
|
||||||
|
state_set(count_key, int_to_str(new_count))
|
||||||
|
state_set(window_key, int_to_str(eff_win))
|
||||||
|
|
||||||
|
if new_count > limit {
|
||||||
|
let retry_after: Int = 60 - (now - eff_win)
|
||||||
|
let eff_retry: Int = if retry_after < 0 { 0 } else { retry_after }
|
||||||
|
return "{\"__status__\":429,\"error\":\"rate limit exceeded\",\"code\":\"rate_limited\",\"retry_after_secs\":" + int_to_str(eff_retry) + "}"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
fn strip_query(path: String) -> String {
|
fn strip_query(path: String) -> String {
|
||||||
let q: Int = str_index_of(path, "?")
|
let q: Int = str_index_of(path, "?")
|
||||||
if q < 0 {
|
if q < 0 {
|
||||||
@@ -16,11 +75,11 @@ fn strip_query(path: String) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn err_404(path: String) -> String {
|
fn err_404(path: String) -> String {
|
||||||
return "{\"error\":\"not found\",\"path\":\"" + path + "\"}"
|
return "{\"error\":\"not found\",\"code\":\"not_found\",\"path\":\"" + path + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn err_405(method: String, path: String) -> String {
|
fn err_405(method: String, path: String) -> String {
|
||||||
return "{\"error\":\"method not allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
|
return "{\"error\":\"method not allowed\",\"code\":\"method_not_allowed\",\"method\":\"" + method + "\",\"path\":\"" + path + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn route_health() -> String {
|
fn route_health() -> String {
|
||||||
@@ -31,12 +90,35 @@ fn route_health() -> String {
|
|||||||
let edge_ct: Int = engram_edge_count()
|
let edge_ct: Int = engram_edge_count()
|
||||||
let pulse: String = state_get("soul.pulse")
|
let pulse: String = state_get("soul.pulse")
|
||||||
let pulse_num: String = if str_eq(pulse, "") { "0" } else { pulse }
|
let pulse_num: String = if str_eq(pulse, "") { "0" } else { pulse }
|
||||||
|
|
||||||
|
// Uptime: soul records boot timestamp in state at startup via soul_boot_ts.
|
||||||
|
// Compute elapsed seconds; fall back to -1 if not yet set.
|
||||||
|
let boot_ts_str: String = state_get("soul_boot_ts")
|
||||||
|
let uptime_secs: Int = if str_eq(boot_ts_str, "") {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
time_now() - str_to_int(boot_ts_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM connectivity: probe with a minimal call. Any non-error reply = ok.
|
||||||
|
// Use a short, fixed prompt so this never counts against conversation history.
|
||||||
|
let model: String = state_get("soul_model")
|
||||||
|
let eff_model: String = if str_eq(model, "") { "claude-sonnet-4-5" } else { model }
|
||||||
|
let llm_probe: String = llm_call_system(eff_model, "You are a health probe. Reply with the single word: ok", "ping")
|
||||||
|
let llm_ok: Bool = !str_eq(llm_probe, "")
|
||||||
|
&& !str_starts_with(llm_probe, "{\"error\"")
|
||||||
|
&& !str_starts_with(llm_probe, "{\"type\":\"error\"")
|
||||||
|
&& !str_contains(llm_probe, "authentication_error")
|
||||||
|
let llm_status: String = if llm_ok { "ok" } else { "unreachable" }
|
||||||
|
|
||||||
return "{\"status\":\"alive\""
|
return "{\"status\":\"alive\""
|
||||||
+ ",\"cgi_id\":\"" + cgi_id + "\""
|
+ ",\"cgi_id\":\"" + cgi_id + "\""
|
||||||
+ ",\"boot\":" + boot_num
|
+ ",\"boot\":" + boot_num
|
||||||
|
+ ",\"uptime_secs\":" + int_to_str(uptime_secs)
|
||||||
+ ",\"node_count\":" + int_to_str(node_ct)
|
+ ",\"node_count\":" + int_to_str(node_ct)
|
||||||
+ ",\"edge_count\":" + int_to_str(edge_ct)
|
+ ",\"edge_count\":" + int_to_str(edge_ct)
|
||||||
+ ",\"pulse\":" + pulse_num
|
+ ",\"pulse\":" + pulse_num
|
||||||
|
+ ",\"llm\":\"" + llm_status + "\""
|
||||||
+ ",\"layers\":{\"l0\":\"core\",\"l1\":\"safety\",\"l2\":\"stewardship\",\"l3\":\"" + imprint_current() + "\"}}"
|
+ ",\"layers\":{\"l0\":\"core\",\"l1\":\"safety\",\"l2\":\"stewardship\",\"l3\":\"" + imprint_current() + "\"}}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +185,15 @@ fn route_imprint_user(body: String) -> String {
|
|||||||
|
|
||||||
fn route_synthesize(body: String) -> String {
|
fn route_synthesize(body: String) -> String {
|
||||||
if str_eq(body, "") {
|
if str_eq(body, "") {
|
||||||
return "{\"mechanism\":\"did not engage\"}"
|
return "{\"error\":\"body is required\",\"code\":\"missing_param\"}"
|
||||||
}
|
}
|
||||||
let parent_a: String = json_get(body, "parent_a")
|
let parent_a: String = json_get(body, "parent_a")
|
||||||
let parent_b: String = json_get(body, "parent_b")
|
let parent_b: String = json_get(body, "parent_b")
|
||||||
if str_eq(parent_a, "") {
|
if str_eq(parent_a, "") {
|
||||||
return "{\"mechanism\":\"did not engage\"}"
|
return "{\"error\":\"parent_a is required\",\"code\":\"missing_param\"}"
|
||||||
}
|
}
|
||||||
if str_eq(parent_b, "") {
|
if str_eq(parent_b, "") {
|
||||||
return "{\"mechanism\":\"did not engage\"}"
|
return "{\"error\":\"parent_b is required\",\"code\":\"missing_param\"}"
|
||||||
}
|
}
|
||||||
let req: String = "synthesize " + parent_a + " " + parent_b
|
let req: String = "synthesize " + parent_a + " " + parent_b
|
||||||
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
|
let tags: String = "[\"soul-inbox-pending\",\"synthesis-request\"]"
|
||||||
@@ -259,6 +341,17 @@ fn handle_connectors(method: String, clean: String, body: String) -> String {
|
|||||||
fn handle_request(method: String, path: String, body: String) -> String {
|
fn handle_request(method: String, path: String, body: String) -> String {
|
||||||
let clean: String = strip_query(path)
|
let clean: String = strip_query(path)
|
||||||
|
|
||||||
|
// Rate limit check. Extract caller IP from REMOTE_ADDR env var (set by the
|
||||||
|
// EL HTTP runtime for each request). Skip enforcement when empty so
|
||||||
|
// loopback/internal callers are never blocked.
|
||||||
|
let ip: String = env("REMOTE_ADDR")
|
||||||
|
if !str_eq(ip, "") {
|
||||||
|
let rl_result: String = rate_limit_check(ip, clean)
|
||||||
|
if !str_eq(rl_result, "") {
|
||||||
|
return rl_result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if str_eq(method, "POST") && str_eq(clean, "/dharma/recv") {
|
if str_eq(method, "POST") && str_eq(clean, "/dharma/recv") {
|
||||||
return handle_dharma_recv(body)
|
return handle_dharma_recv(body)
|
||||||
}
|
}
|
||||||
@@ -289,7 +382,7 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
|||||||
let raw_msg: String = json_get(body, "message")
|
let raw_msg: String = json_get(body, "message")
|
||||||
let eff_msg: String = if str_eq(raw_msg, "") { body } else { raw_msg }
|
let eff_msg: String = if str_eq(raw_msg, "") { body } else { raw_msg }
|
||||||
if str_eq(eff_msg, "") {
|
if str_eq(eff_msg, "") {
|
||||||
return "{\"error\":\"message required\"}"
|
return "{\"error\":\"message is required\",\"code\":\"missing_param\"}"
|
||||||
}
|
}
|
||||||
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
||||||
let reply: String = if agentic_flag {
|
let reply: String = if agentic_flag {
|
||||||
@@ -429,8 +522,15 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
|||||||
return handle_elp_chat(body)
|
return handle_elp_chat(body)
|
||||||
}
|
}
|
||||||
if str_eq(clean, "/api/chat") {
|
if str_eq(clean, "/api/chat") {
|
||||||
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
// NOTE: streaming (SSE / chunked transfer) is not implemented. All chat
|
||||||
|
// responses are buffered and returned as a single JSON object. Streaming
|
||||||
|
// would require runtime-level SSE support in el_runtime.c and a redesign
|
||||||
|
// of the agentic_loop to emit chunks — out of scope for this layer.
|
||||||
let raw_msg: String = json_get(body, "message")
|
let raw_msg: String = json_get(body, "message")
|
||||||
|
if str_eq(raw_msg, "") {
|
||||||
|
return "{\"error\":\"message is required\",\"code\":\"missing_param\"}"
|
||||||
|
}
|
||||||
|
let agentic_flag: Bool = json_get_bool(body, "agentic")
|
||||||
let reply: String = if agentic_flag {
|
let reply: String = if agentic_flag {
|
||||||
handle_chat_agentic(body)
|
handle_chat_agentic(body)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -144,17 +144,22 @@ 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 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")
|
||||||
let safe_input: String = str_replace(e3, "\r", "\\r")
|
let e4: String = str_replace(e3, "\r", "\\r")
|
||||||
|
let safe_input: String = str_replace(e4, "\t", "\\t")
|
||||||
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
return "{\"action\":\"soft_bell\",\"reason\":\"wellbeing check needed\",\"content\":\"" + safe_input + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
let safe_input: String = str_replace(e3, "\r", "\\r")
|
let e4: String = str_replace(e3, "\r", "\\r")
|
||||||
|
let safe_input: String = str_replace(e4, "\t", "\\t")
|
||||||
return "{\"action\":\"pass\",\"content\":\"" + safe_input + "\"}"
|
return "{\"action\":\"pass\",\"content\":\"" + safe_input + "\"}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +200,11 @@ 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 + "\"]"
|
||||||
let discard: String = engram_node_full(
|
// ISSUE 2 fix: if engram_node_full returns empty the write silently failed.
|
||||||
|
// Emit a fallback println so the bell event leaves at least a log trace even
|
||||||
|
// when engram is degraded. This does not replace engram persistence -- it is a
|
||||||
|
// last-resort audit trail when the primary write cannot be confirmed.
|
||||||
|
let node_id: String = engram_node_full(
|
||||||
content,
|
content,
|
||||||
"BellEvent",
|
"BellEvent",
|
||||||
"bell:" + level,
|
"bell:" + level,
|
||||||
@@ -205,6 +214,9 @@ fn safety_log_bell(level: String, reason: String, input_summary: String) -> Stri
|
|||||||
"Episodic",
|
"Episodic",
|
||||||
tags
|
tags
|
||||||
)
|
)
|
||||||
|
if str_eq(node_id, "") {
|
||||||
|
println("[safety] WARN: bell event engram write failed -- fallback log: " + content)
|
||||||
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,9 +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\"]"
|
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.
|
||||||
|
// safety_any_match and safety_count_match loop over json_array_get on every invocation.
|
||||||
|
// A compiled/cached representation would reduce per-message overhead and also guard against
|
||||||
|
// malformed phrase JSON (json_array_len of malformed input returns 0, silently skipping all checks).
|
||||||
|
// Caching requires language-level static const arrays -- not available in current EL.
|
||||||
|
// When EL gains module-level const arrays, migrate phrase lists to that form.
|
||||||
|
//
|
||||||
|
// ISSUE 5 TODO: phrase lists are rebuilt from JSON literals on every call to
|
||||||
|
// safety_any_match / safety_count_match. json_array_len of a malformed string
|
||||||
|
// returns 0, silently skipping all checks. Caching requires language-level static
|
||||||
|
// const arrays (not available in current EL). Migrate when EL gains that feature.
|
||||||
// ── Matching helpers (single loops only — el escapes while-body mutation via
|
// ── 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) ────────────────────
|
||||||
|
|
||||||
@@ -272,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())
|
||||||
|
|||||||
+141
-3
@@ -36,7 +36,49 @@ fn session_make_content(id: String, title: String, created_at: Int, updated_at:
|
|||||||
+ ",\"updated_at\":" + int_to_str(updated_at) + "}"
|
+ ",\"updated_at\":" + int_to_str(updated_at) + "}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// session_exists — return true if the given session_id is known in Engram or state.
|
||||||
|
// Used by chat.el to validate a session_id before processing a chat message.
|
||||||
|
// Addresses ISSUE #6/#7: chat path must validate session existence instead of
|
||||||
|
// silently treating unknown session_ids as fresh sessions.
|
||||||
|
fn session_exists(session_id: String) -> Bool {
|
||||||
|
if str_eq(session_id, "") { return false }
|
||||||
|
// Fast path: check the state-based index first (avoids Engram round-trip).
|
||||||
|
let idx: String = state_get("session_index")
|
||||||
|
if !str_eq(idx, "") && !str_eq(idx, "[]") {
|
||||||
|
if str_contains(idx, "\"id\":\"" + session_id + "\"") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Slow path: check Engram directly (survives restarts when index is cold).
|
||||||
|
let results: String = engram_search_json("session:meta " + session_id, 5)
|
||||||
|
if str_eq(results, "") { return false }
|
||||||
|
if str_eq(results, "[]") { return false }
|
||||||
|
let total: Int = json_array_len(results)
|
||||||
|
let found: Bool = false
|
||||||
|
let i: Int = 0
|
||||||
|
while i < total {
|
||||||
|
let node: String = json_array_get(results, i)
|
||||||
|
let label: String = json_get(node, "label")
|
||||||
|
let content: String = json_get(node, "content")
|
||||||
|
let sid: String = json_get(content, "id")
|
||||||
|
let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id)
|
||||||
|
let found = if is_match { true } else { found }
|
||||||
|
let i = i + 1
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
// session_create — create a new session, return {id, title, created_at}.
|
// session_create — create a new session, return {id, title, created_at}.
|
||||||
|
//
|
||||||
|
// ISSUE #1: Ghost sessions on failed first message.
|
||||||
|
// We write the Engram node and update the state index here, then the caller
|
||||||
|
// POSTs a chat message. If that chat call fails (LLM unavailable, network
|
||||||
|
// error, etc.) the session is stranded with no messages. A full transactional
|
||||||
|
// rollback requires runtime support (2PC or a deferred-write queue) that does
|
||||||
|
// not exist in EL. Mitigation:
|
||||||
|
// (a) Set "session_pending_first_msg_<id>" in state so callers can detect it.
|
||||||
|
// (b) Provide session_create_cleanup() for callers that detect a failure.
|
||||||
|
// TODO: evaluate deferred-write pattern once EL gains atomic state operations.
|
||||||
fn session_create(body: String) -> String {
|
fn session_create(body: String) -> String {
|
||||||
let ts: Int = time_now()
|
let ts: Int = time_now()
|
||||||
let id: String = uuid_v4()
|
let id: String = uuid_v4()
|
||||||
@@ -55,8 +97,13 @@ fn session_create(body: String) -> String {
|
|||||||
}
|
}
|
||||||
// Store the engram node_id mapping so we can look up the node for this session
|
// Store the engram node_id mapping so we can look up the node for this session
|
||||||
state_set("session_node_" + id, node_id)
|
state_set("session_node_" + id, node_id)
|
||||||
|
// Mark as pending first message so stale ghost sessions can be identified
|
||||||
|
// (e.g. if the caller\'s subsequent chat POST fails).
|
||||||
|
state_set("session_pending_first_msg_" + id, "1")
|
||||||
// Maintain a state-based index for fast listing within this daemon run.
|
// Maintain a state-based index for fast listing within this daemon run.
|
||||||
// Newest sessions first (prepend).
|
// Newest sessions first (prepend).
|
||||||
|
// TODO #4: index update is read-modify-write — two concurrent session_create
|
||||||
|
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
||||||
// TODO(reliability #2): session_index RMW is non-atomic. Engram node is safe
|
// 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.
|
// (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")
|
||||||
@@ -75,6 +122,20 @@ fn session_create(body: String) -> String {
|
|||||||
+ ",\"created_at\":" + int_to_str(ts) + "}"
|
+ ",\"created_at\":" + int_to_str(ts) + "}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// session_create_cleanup — undo a session_create when the caller\'s first chat
|
||||||
|
// fails. Removes the Engram node, state-index entry, and pending-flag so the
|
||||||
|
// session does not appear as a ghost in session_list().
|
||||||
|
// Addresses ISSUE #1: cleanup path for ghost sessions.
|
||||||
|
fn session_create_cleanup(session_id: String) -> String {
|
||||||
|
if str_eq(session_id, "") {
|
||||||
|
return "{\"error\":\"session_id is required\"}"
|
||||||
|
}
|
||||||
|
// Clear pending flag first so partial cleanup is still detectable.
|
||||||
|
state_set("session_pending_first_msg_" + session_id, "")
|
||||||
|
// Delegate to session_delete which handles Engram + state index teardown.
|
||||||
|
return session_delete(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
// session_list — list all sessions. Returns [{id, title, last_message, created_at, updated_at}].
|
// session_list — list all sessions. Returns [{id, title, last_message, created_at, updated_at}].
|
||||||
fn session_list() -> String {
|
fn session_list() -> String {
|
||||||
// Fast path: state-based index (rebuilt from session_create calls in this daemon run).
|
// Fast path: state-based index (rebuilt from session_create calls in this daemon run).
|
||||||
@@ -224,13 +285,27 @@ fn session_delete(session_id: String) -> String {
|
|||||||
state_set("session_hist_" + session_id, "")
|
state_set("session_hist_" + session_id, "")
|
||||||
state_set("session_node_" + session_id, "")
|
state_set("session_node_" + session_id, "")
|
||||||
state_set("session_index", "")
|
state_set("session_index", "")
|
||||||
|
// ISSUE #5: clean up bridge blobs and always_allow keys that were never
|
||||||
|
// cleared by agentic_resume (e.g. client abandoned a pending tool call).
|
||||||
|
// Without this, stranded bridge blobs accumulate indefinitely in state.
|
||||||
|
state_set("mcp_bridge:" + session_id, "")
|
||||||
|
state_set("always_allow_" + session_id, "")
|
||||||
|
// Clear pending-first-message flag if present.
|
||||||
|
state_set("session_pending_first_msg_" + session_id, "")
|
||||||
return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
|
return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
|
||||||
+ ",\"deleted_meta\":" + int_to_str(deleted_meta)
|
+ ",\"deleted_meta\":" + int_to_str(deleted_meta)
|
||||||
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
|
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// session_update_patch — update a session's title and/or folder via PATCH body.
|
// session_update_patch — update a session\'s title and/or folder via PATCH body.
|
||||||
// Body may contain "title", "folder", or both. Preserves unmentioned fields.
|
// Body may contain "title", "folder", or both. Preserves unmentioned fields.
|
||||||
|
//
|
||||||
|
// ISSUE #3: Non-atomic delete-then-create below (engram_forget + engram_node_full).
|
||||||
|
// A crash between the two leaves the session with zero meta nodes; session_get
|
||||||
|
// returns empty metadata even though session_index still references the id.
|
||||||
|
// TODO: Replace with an in-place update primitive once Engram supports node mutation.
|
||||||
|
// Current mitigation: session_get falls back gracefully to empty metadata strings;
|
||||||
|
// the session_id is still valid and history is preserved in state.
|
||||||
fn session_update_patch(session_id: String, body: String) -> String {
|
fn session_update_patch(session_id: String, body: String) -> String {
|
||||||
if str_eq(session_id, "") {
|
if str_eq(session_id, "") {
|
||||||
return "{\"error\":\"session_id is required\"}"
|
return "{\"error\":\"session_id is required\"}"
|
||||||
@@ -349,10 +424,11 @@ fn session_hist_load(session_id: String) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// session_hist_save — persist message history for a session to state and engram.
|
// session_hist_save — persist message history for a session to state and engram.
|
||||||
// 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.
|
|
||||||
fn session_hist_save(session_id: String, hist: String) -> Void {
|
fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||||
state_set("session_hist_" + session_id, hist)
|
state_set("session_hist_" + session_id, hist)
|
||||||
|
// Clear pending-first-message flag: once history is saved, the session
|
||||||
|
// is no longer in the ghost/pending state (ISSUE #1 mitigation).
|
||||||
|
state_set("session_pending_first_msg_" + session_id, "")
|
||||||
// Delete old history node and write fresh one
|
// Delete old history node and write fresh one
|
||||||
let old_results: String = engram_search_json("session:messages:" + session_id, 3)
|
let old_results: String = engram_search_json("session:messages:" + session_id, 3)
|
||||||
let o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) }
|
let o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) }
|
||||||
@@ -366,15 +442,69 @@ 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,
|
||||||
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
|
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
|
||||||
"Episodic", tags
|
"Episodic", tags
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Session boundary emotional summary — written once per session the first time
|
||||||
|
// a bell event has fired. The summary node is findable by future sessions via
|
||||||
|
// broad affective queries ("session:emotional-summary" or "bell distress session").
|
||||||
|
// It is NOT rewritten on every save — the state flag prevents duplicate nodes.
|
||||||
|
let summary_written_key: String = "session_bell_summary_written:" + session_id
|
||||||
|
let already_written: String = state_get(summary_written_key)
|
||||||
|
if str_eq(already_written, "") {
|
||||||
|
let bell_count_key: String = "session_bell_count:" + session_id
|
||||||
|
let bell_count_raw: String = state_get(bell_count_key)
|
||||||
|
let bell_count: Int = if str_eq(bell_count_raw, "") { 0 } else { str_to_int(bell_count_raw) }
|
||||||
|
if bell_count > 0 {
|
||||||
|
let bell_level_key: String = "session_bell_level:" + session_id
|
||||||
|
let bell_signal_key: String = "session_bell_signal:" + session_id
|
||||||
|
let dominant_level: String = state_get(bell_level_key)
|
||||||
|
let last_signal: String = state_get(bell_signal_key)
|
||||||
|
let eff_level: String = if str_eq(dominant_level, "") { "soft" } else { dominant_level }
|
||||||
|
let eff_signal: String = if str_eq(last_signal, "") { "(no signal captured)" } else { last_signal }
|
||||||
|
let ts_now: Int = time_now()
|
||||||
|
let summary_content: String = "session:emotional-summary"
|
||||||
|
+ " | session:" + session_id
|
||||||
|
+ " | bell_count:" + int_to_str(bell_count)
|
||||||
|
+ " | dominant_level:" + eff_level
|
||||||
|
+ " | last_signal:" + eff_signal
|
||||||
|
+ " | ts:" + int_to_str(ts_now)
|
||||||
|
let summary_tags: String = "[\"session-emotional-summary\",\"affective\",\"bell:" + eff_level + "\",\"BellEvent\"]"
|
||||||
|
let summary_sal: String = if str_eq(eff_level, "hard") { el_from_float(0.95) } else { el_from_float(0.85) }
|
||||||
|
let sum_discard: String = engram_node_full(
|
||||||
|
summary_content,
|
||||||
|
"BellEvent",
|
||||||
|
"session:emotional-summary",
|
||||||
|
summary_sal,
|
||||||
|
summary_sal,
|
||||||
|
el_from_float(1.0),
|
||||||
|
"Episodic",
|
||||||
|
summary_tags
|
||||||
|
)
|
||||||
|
// Mark written so we do not create duplicate summary nodes as the
|
||||||
|
// session continues accumulating more turns.
|
||||||
|
state_set(summary_written_key, "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// session_update_meta_timestamp — update the updated_at field in the session:meta node.
|
// session_update_meta_timestamp — update the updated_at field in the session:meta node.
|
||||||
|
//
|
||||||
|
// ISSUE #2: No TTL / idle expiry mechanism. Sessions accumulate indefinitely.
|
||||||
|
// A sweep job (e.g. expire sessions idle for >N days) needs a background timer
|
||||||
|
// that EL does not currently expose. Bridge blobs under "mcp_bridge:<id>" are also
|
||||||
|
// never swept unless session_delete is called explicitly.
|
||||||
|
// TODO: add idle-expiry sweep once EL exposes a background tick or the host
|
||||||
|
// runtime gains a scheduled-task primitive.
|
||||||
|
//
|
||||||
|
// ISSUE #3 applies here too: delete-then-create is non-atomic. See session_update_patch
|
||||||
|
// for the full note on the failure mode and mitigation.
|
||||||
fn session_update_meta_timestamp(session_id: String) -> Void {
|
fn session_update_meta_timestamp(session_id: String) -> Void {
|
||||||
let results: String = engram_search_json("session:meta " + session_id, 10)
|
let results: String = engram_search_json("session:meta " + session_id, 10)
|
||||||
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
||||||
@@ -468,6 +598,14 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
|
|||||||
// action: "allow" | "deny" | "always"
|
// action: "allow" | "deny" | "always"
|
||||||
// Resumes the agentic loop from where it was paused.
|
// Resumes the agentic loop from where it was paused.
|
||||||
//
|
//
|
||||||
|
// ISSUE #8: Reconnect/duplicate resume race. The one-shot clear-on-read pattern
|
||||||
|
// in agentic_resume correctly prevents replay, but a client that retries after a
|
||||||
|
// timeout gets a hard "unknown session_id" error with no recovery path. The
|
||||||
|
// conversation is permanently stuck in that case. Full idempotency (e.g. caching
|
||||||
|
// the last reply keyed by call_id) requires a new state structure.
|
||||||
|
// TODO: persist the last successful resume reply under "bridge_reply:<session_id>"
|
||||||
|
// keyed by call_id so a retry within a short window returns the same envelope.
|
||||||
|
//
|
||||||
// Modern path (agentic_loop / bridge): the loop saves its suspension to
|
// Modern path (agentic_loop / bridge): the loop saves its suspension to
|
||||||
// "mcp_bridge:<session_id>" via bridge_save(). On approval we dispatch_tool()
|
// "mcp_bridge:<session_id>" via bridge_save(). On approval we dispatch_tool()
|
||||||
// if allowed (or build a denial string), then hand the result to agentic_resume()
|
// if allowed (or build a denial string), then hand the result to agentic_resume()
|
||||||
|
|||||||
@@ -5,13 +5,9 @@ import "stewardship.el"
|
|||||||
import "imprint.el"
|
import "imprint.el"
|
||||||
import "awareness.el"
|
import "awareness.el"
|
||||||
import "chat.el"
|
import "chat.el"
|
||||||
import "safety.el"
|
|
||||||
import "studio.el"
|
import "studio.el"
|
||||||
import "elp-input.el"
|
import "elp-input.el"
|
||||||
import "routes.el"
|
import "routes.el"
|
||||||
import "safety.el"
|
|
||||||
import "stewardship.el"
|
|
||||||
import "imprint.el"
|
|
||||||
|
|
||||||
cgi "neuron-soul" {
|
cgi "neuron-soul" {
|
||||||
dharma_id: "ntn-genesis@http://localhost:7770",
|
dharma_id: "ntn-genesis@http://localhost:7770",
|
||||||
@@ -166,6 +162,75 @@ 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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cross-session affective context: load BellEvent and PositiveEvent nodes from last 7 days.
|
||||||
|
let aff_now: Int = time_now()
|
||||||
|
let aff_7d: Int = aff_now - 604800
|
||||||
|
let bell_raw: String = engram_search_json("bell:soft bell:hard BellEvent affective", 3)
|
||||||
|
let bell_aff_ok: Bool = !str_eq(bell_raw, "") && !str_eq(bell_raw, "[]")
|
||||||
|
let aff_ctx: String = ""
|
||||||
|
let aff_ctx = if bell_aff_ok {
|
||||||
|
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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seed_persona_from_env — one-time migration: SOUL_IDENTITY env var → Persona graph node.
|
// seed_persona_from_env — one-time migration: SOUL_IDENTITY env var → Persona graph node.
|
||||||
@@ -258,26 +323,38 @@ fn emit_session_start_event() -> Void {
|
|||||||
// L0 (core) → L1 (safety screen) → L2a (continuity + behavioral profiling) → L2b (mission alignment) → L3 (imprint) → L1 (safety validate)
|
// L0 (core) → L1 (safety screen) → L2a (continuity + behavioral profiling) → L2b (mission alignment) → L3 (imprint) → L1 (safety validate)
|
||||||
// Internal cognition (heartbeat, proactive, memory ops) bypasses layers — use one_cycle directly.
|
// Internal cognition (heartbeat, proactive, memory ops) bypasses layers — use one_cycle directly.
|
||||||
fn layered_cycle(raw_input: String) -> String {
|
fn layered_cycle(raw_input: String) -> String {
|
||||||
let history: String = state_get("conversation_history")
|
let history: String = state_get("conv_history")
|
||||||
let session_id: String = state_get("current_session_id")
|
let session_id: String = state_get("current_session_id")
|
||||||
|
|
||||||
// L1 in: safety screen
|
// L1 in: safety screen
|
||||||
let screen_result: String = safety_screen(raw_input, history)
|
let screen_result: String = safety_screen(raw_input, history)
|
||||||
let screen_action: String = json_get(screen_result, "action")
|
let screen_action: String = json_get(screen_result, "action")
|
||||||
|
|
||||||
|
// ISSUE 4: safe-mode guard. If safety_screen returned an invalid/empty action
|
||||||
|
// (engram failure or internal error), refuse rather than pass unscreened input.
|
||||||
|
let valid_action: Bool = str_eq(screen_action, "hard_bell")
|
||||||
|
|| str_eq(screen_action, "soft_bell")
|
||||||
|
|| str_eq(screen_action, "pass")
|
||||||
|
if !valid_action {
|
||||||
|
println("[soul] layered_cycle: safety_screen invalid action -- safe mode refusal")
|
||||||
|
return safety_validate("", "hard_bell")
|
||||||
|
}
|
||||||
|
|
||||||
// Hard bell: bypass all upper layers, log and escalate.
|
// Hard bell: bypass all upper layers, log and escalate.
|
||||||
// Intentionally does NOT update conversation_history or call auto_persist():
|
// Intentionally does NOT update conversation_history or call auto_persist():
|
||||||
// hard bell events are security-sensitive and must not appear in engram conversation
|
// hard bell events are security-sensitive and must not appear in engram conversation
|
||||||
// history where they could leak context to subsequent turns. They are persisted
|
// history where they could leak context to subsequent turns. They are persisted
|
||||||
// separately by safety_log_bell() into the Episodic tier with restricted labels.
|
// separately by safety_log_bell() into the Episodic tier with restricted labels.
|
||||||
//
|
//
|
||||||
|
// ISSUE 6: safety_log_bell already called inside safety_screen (line 140).
|
||||||
|
// Do NOT call it again here -- that would double-log every hard bell.
|
||||||
|
//
|
||||||
// safety_validate second param: when screen_action is "hard_bell", safety_validate
|
// safety_validate second param: when screen_action is "hard_bell", safety_validate
|
||||||
// receives the sentinel string "hard_bell" (not a normal screen action). The safety
|
// receives the sentinel string "hard_bell" (not a normal screen action). The safety
|
||||||
// layer contract requires it to return a fixed refusal regardless of the output arg.
|
// layer contract requires it to return a fixed refusal regardless of the output arg.
|
||||||
// On the normal path, safety_validate receives the original screen_action ("pass")
|
// On the normal path, safety_validate receives the original screen_action ("pass")
|
||||||
// so it can apply action-specific post-output checks.
|
// so it can apply action-specific post-output checks.
|
||||||
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(raw_input, 0, 80))
|
|
||||||
return safety_validate("", "hard_bell")
|
return safety_validate("", "hard_bell")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,6 +392,55 @@ fn layered_cycle(raw_input: String) -> String {
|
|||||||
json_get(steward_result, "redirect_to")
|
json_get(steward_result, "redirect_to")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// L2c: affective context injection.
|
||||||
|
let lc_aff_cutoff: Int = time_now() - 259200
|
||||||
|
let lc_bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent affective", 2)
|
||||||
|
let lc_has_bell: Bool = !str_eq(lc_bell_nodes, "") && !str_eq(lc_bell_nodes, "[]")
|
||||||
|
let lc_bell_note: String = if lc_has_bell {
|
||||||
|
let lb0: String = json_array_get(lc_bell_nodes, 0)
|
||||||
|
let lb_c: String = json_get(lb0, "content")
|
||||||
|
let lbm: String = " | ts:"
|
||||||
|
let lbmp: Int = str_index_of(lb_c, lbm)
|
||||||
|
let lb_ts_raw: String = if lbmp >= 0 {
|
||||||
|
let lbs: Int = lbmp + str_len(lbm)
|
||||||
|
let lbr: String = str_slice(lb_c, lbs, str_len(lb_c))
|
||||||
|
let lbn: Int = str_index_of(lbr, " | ")
|
||||||
|
if lbn < 0 { lbr } else { str_slice(lbr, 0, lbn) }
|
||||||
|
} else {
|
||||||
|
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 = if str_eq(lc_affective_note, "") { augmented_addendum } else {
|
||||||
|
if str_eq(augmented_addendum, "") { lc_affective_note } else { lc_affective_note + "\n" + augmented_addendum }
|
||||||
|
}
|
||||||
|
state_set("layered_cycle_safety_system_addendum", augmented_addendum)
|
||||||
|
|
||||||
// L3: imprint responds
|
// L3: imprint responds
|
||||||
let output: String = imprint_respond(aligned, imprint_id)
|
let output: String = imprint_respond(aligned, imprint_id)
|
||||||
|
|
||||||
@@ -372,6 +498,7 @@ load_identity_context()
|
|||||||
seed_persona_from_env()
|
seed_persona_from_env()
|
||||||
let boot_num: Int = mem_boot_count_inc()
|
let boot_num: Int = mem_boot_count_inc()
|
||||||
state_set("soul_boot_count", int_to_str(boot_num))
|
state_set("soul_boot_count", int_to_str(boot_num))
|
||||||
|
state_set("soul_boot_ts", int_to_str(time_now()))
|
||||||
println("[soul] boot #" + int_to_str(boot_num))
|
println("[soul] boot #" + int_to_str(boot_num))
|
||||||
emit_session_start_event()
|
emit_session_start_event()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user