1use std::sync::Arc;
30use std::time::Duration;
31
32use tracing::Instrument as _;
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
35
36pub use zeph_config::memory::EmGraphConfig;
37
38use zeph_common::SessionId;
39use zeph_common::llm_response::extract_json_array_slice;
40use zeph_db::ActiveDialect;
41
42use crate::error::MemoryError;
43use crate::store::SqliteStore;
44use crate::types::MessageId;
45
46const MAX_CAUSAL_VISITED: usize = 400;
49
50#[derive(Debug, Clone)]
54pub struct EpisodicEvent {
55 pub id: i64,
57 pub session_id: SessionId,
59 pub message_id: MessageId,
61 pub event_type: String,
63 pub summary: String,
65 pub embedding: Option<Vec<u8>>,
67 pub created_at: i64,
69}
70
71#[derive(Debug, Clone)]
73pub struct CausalLink {
74 pub id: i64,
76 pub cause_event_id: i64,
78 pub effect_event_id: i64,
80 pub strength: f32,
82 pub created_at: i64,
84}
85
86pub async fn extract_events(
101 provider: &Arc<AnyProvider>,
102 content: &str,
103 session_id: &str,
104 message_id: MessageId,
105 config: &EmGraphConfig,
106) -> Vec<EpisodicEvent> {
107 let span = tracing::debug_span!("memory.em_graph.extract_events", message_id = message_id.0);
108
109 async move {
110 if !config.enabled {
111 return vec![];
112 }
113
114 let snippet = content.chars().take(2000).collect::<String>();
115
116 let prompt = format!(
117 "Identify episodic events in the following conversation turn. \
118 An event is a concrete action, decision, discovery, or error. \
119 Return a JSON array of objects with fields: \
120 {{\"event_type\": \"<type>\", \"summary\": \"<one sentence>\"}}. \
121 Types: decision, discovery, error, tool_use, question, answer, other. \
122 Return [] if no notable events. Output JSON only.\n\nTurn:\n{snippet}"
123 );
124
125 let messages = vec![
126 Message {
127 role: Role::System,
128 content: "You are an episodic memory extractor. Extract concrete events from \
129 conversation turns as structured JSON. Output only valid JSON, no preamble."
130 .to_owned(),
131 parts: vec![],
132 metadata: MessageMetadata::default(),
133 },
134 Message {
135 role: Role::User,
136 content: prompt,
137 parts: vec![],
138 metadata: MessageMetadata::default(),
139 },
140 ];
141
142 let raw = match tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages)).await {
143 Ok(Ok(r)) => r,
144 Ok(Err(e)) => {
145 tracing::warn!(error = %e, "em_graph: event extraction LLM call failed");
146 return vec![];
147 }
148 Err(_) => {
149 tracing::warn!("em_graph: event extraction timed out");
150 return vec![];
151 }
152 };
153
154 parse_events_response(&raw, session_id, message_id)
155 }
156 .instrument(span)
157 .await
158}
159
160fn parse_events_response(raw: &str, session_id: &str, message_id: MessageId) -> Vec<EpisodicEvent> {
161 let json_str = extract_json_array_slice(raw).unwrap_or("[]");
162
163 let values: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap_or_default();
164
165 values
166 .into_iter()
167 .filter_map(|v| {
168 let event_type = v.get("event_type")?.as_str()?.to_owned();
169 let summary = v.get("summary")?.as_str()?.to_owned();
170 if summary.is_empty() {
171 return None;
172 }
173 Some(EpisodicEvent {
174 id: 0,
175 session_id: SessionId::new(session_id),
176 message_id,
177 event_type,
178 summary,
179 embedding: None,
180 created_at: 0,
181 })
182 })
183 .collect()
184}
185
186pub async fn link_events(
200 provider: &Arc<AnyProvider>,
201 new_events: &[EpisodicEvent],
202 recent_events: &[EpisodicEvent],
203 config: &EmGraphConfig,
204) -> Vec<CausalLink> {
205 let span = tracing::debug_span!(
206 "memory.em_graph.link_events",
207 new_count = new_events.len(),
208 recent_count = recent_events.len()
209 );
210
211 async move {
212 if !config.enabled || new_events.is_empty() || recent_events.is_empty() {
213 return vec![];
214 }
215
216 let new_desc: Vec<String> = new_events
218 .iter()
219 .enumerate()
220 .map(|(i, e)| {
221 let s: String = e.summary.chars().take(200).collect();
222 format!("NEW[{i}] (id={}): {s}", e.id)
223 })
224 .collect();
225
226 let recent_desc: Vec<String> = recent_events
227 .iter()
228 .enumerate()
229 .map(|(i, e)| {
230 let s: String = e.summary.chars().take(200).collect();
231 format!("RECENT[{i}] (id={}): {s}", e.id)
232 })
233 .collect();
234
235 let prompt = format!(
236 "Given these recent events and new events, identify causal relationships \
237 (cause → effect). Return a JSON array of objects: \
238 {{\"cause_id\": <event_id>, \"effect_id\": <event_id>, \"strength\": 0.0-1.0}}. \
239 Only include strong causal links (strength >= 0.5). Output [] if none.\n\n\
240 Recent events:\n{}\n\nNew events:\n{}",
241 recent_desc.join("\n"),
242 new_desc.join("\n"),
243 );
244
245 let messages = vec![
246 Message {
247 role: Role::System,
248 content: "You are a causal reasoning engine. Identify cause-and-effect \
249 relationships between events. Output only valid JSON."
250 .to_owned(),
251 parts: vec![],
252 metadata: MessageMetadata::default(),
253 },
254 Message {
255 role: Role::User,
256 content: prompt,
257 parts: vec![],
258 metadata: MessageMetadata::default(),
259 },
260 ];
261
262 let raw =
263 match tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages)).await {
264 Ok(Ok(r)) => r,
265 Ok(Err(e)) => {
266 tracing::warn!(error = %e, "em_graph: causal link LLM call failed");
267 return vec![];
268 }
269 Err(_) => {
270 tracing::warn!("em_graph: causal link detection timed out");
271 return vec![];
272 }
273 };
274
275 parse_links_response(&raw)
276 }
277 .instrument(span)
278 .await
279}
280
281fn parse_links_response(raw: &str) -> Vec<CausalLink> {
282 let json_str = extract_json_array_slice(raw).unwrap_or("[]");
283
284 let values: Vec<serde_json::Value> = serde_json::from_str(json_str).unwrap_or_default();
285
286 values
287 .into_iter()
288 .filter_map(|v| {
289 let cause_id = v.get("cause_id")?.as_i64()?;
290 let effect_id = v.get("effect_id")?.as_i64()?;
291 #[allow(clippy::cast_possible_truncation)]
292 let strength = v
293 .get("strength")
294 .and_then(serde_json::Value::as_f64)
295 .map_or(0.5, |s| s.clamp(0.0, 1.0) as f32);
296 if strength < 0.5 {
297 return None;
298 }
299 Some(CausalLink {
300 id: 0,
301 cause_event_id: cause_id,
302 effect_event_id: effect_id,
303 strength,
304 created_at: 0,
305 })
306 })
307 .collect()
308}
309
310pub async fn store_events(
321 store: &SqliteStore,
322 events: &mut [EpisodicEvent],
323) -> Result<(), MemoryError> {
324 if events.is_empty() {
325 return Ok(());
326 }
327 let mut tx = store.pool().begin().await?;
328 for event in events.iter_mut() {
329 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
330 let raw = format!(
331 "INSERT INTO episodic_events (session_id, message_id, event_type, summary, created_at) \
332 VALUES (?, ?, ?, ?, {epoch_now}) \
333 RETURNING id"
334 );
335 let sql = zeph_db::rewrite_placeholders(&raw);
336 let id = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(sql))
337 .bind(event.session_id.as_str())
338 .bind(event.message_id.0)
339 .bind(&event.event_type)
340 .bind(&event.summary)
341 .fetch_one(&mut *tx)
342 .await?;
343 event.id = id;
344 }
345 tx.commit().await?;
346 Ok(())
347}
348
349pub async fn store_links(store: &SqliteStore, links: &[CausalLink]) -> Result<(), MemoryError> {
360 if links.is_empty() {
361 return Ok(());
362 }
363 let mut tx = store.pool().begin().await?;
364 for link in links {
365 let epoch_now = <ActiveDialect as zeph_db::dialect::Dialect>::EPOCH_NOW;
366 let raw = format!(
367 "INSERT INTO causal_links \
368 (cause_event_id, effect_event_id, strength, created_at) \
369 VALUES (?, ?, ?, {epoch_now}) \
370 ON CONFLICT (cause_event_id, effect_event_id) DO NOTHING"
371 );
372 let sql = zeph_db::rewrite_placeholders(&raw);
373 sqlx::query(sqlx::AssertSqlSafe(sql))
374 .bind(link.cause_event_id)
375 .bind(link.effect_event_id)
376 .bind(link.strength)
377 .execute(&mut *tx)
378 .await?;
379 }
380 tx.commit().await?;
381 Ok(())
382}
383
384pub async fn fetch_recent_events(
394 store: &SqliteStore,
395 session_id: &str,
396 limit: usize,
397) -> Result<Vec<EpisodicEvent>, MemoryError> {
398 let rows = sqlx::query_as::<_, (i64, String, i64, String, String, i64)>(zeph_db::sql!(
399 "SELECT id, session_id, message_id, event_type, summary, created_at
400 FROM episodic_events
401 WHERE session_id = ?
402 ORDER BY created_at DESC
403 LIMIT ?"
404 ))
405 .bind(session_id)
406 .bind(i64::try_from(limit).unwrap_or(i64::MAX))
407 .fetch_all(store.pool())
408 .await?;
409
410 Ok(rows
411 .into_iter()
412 .map(
413 |(id, session_id, message_id, event_type, summary, created_at)| EpisodicEvent {
414 id,
415 session_id: SessionId::new(session_id),
416 message_id: MessageId(message_id),
417 event_type,
418 summary,
419 embedding: None,
420 created_at,
421 },
422 )
423 .collect())
424}
425
426pub async fn recall_episodic_causal(
435 store: &SqliteStore,
436 seed_event_id: i64,
437 session_id: &str,
438 max_depth: u32,
439 config: &EmGraphConfig,
440) -> Result<Vec<EpisodicEvent>, MemoryError> {
441 let span = tracing::debug_span!("memory.em_graph.causal_recall", seed_event_id, max_depth);
442
443 if !config.enabled {
444 return Ok(vec![]);
445 }
446
447 let pool = store.pool().clone();
449 let session_id = session_id.to_owned();
450
451 async move {
452 let mut visited: Vec<i64> = vec![seed_event_id];
453 let mut frontier: Vec<i64> = vec![seed_event_id];
454
455 for depth in 0..max_depth {
456 if frontier.is_empty() || visited.len() >= MAX_CAUSAL_VISITED {
457 break;
458 }
459
460 let frontier_ph = frontier.iter().map(|_| "?").collect::<Vec<_>>().join(",");
461 let visited_ph = visited.iter().map(|_| "?").collect::<Vec<_>>().join(",");
462
463 let query = zeph_db::rewrite_placeholders(&format!(
464 "SELECT DISTINCT effect_event_id FROM causal_links
465 WHERE cause_event_id IN ({frontier_ph})
466 AND effect_event_id NOT IN ({visited_ph})"
467 ));
468
469 let mut q = sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(query));
470 for &id in &frontier {
471 q = q.bind(id);
472 }
473 for &id in &visited {
474 q = q.bind(id);
475 }
476
477 let next: Vec<i64> = q.fetch_all(&pool).await?;
478
479 tracing::debug!(depth, next_count = next.len(), "em_graph: causal hop");
480 visited.extend_from_slice(&next);
481 frontier = next;
482 }
483
484 if visited.is_empty() {
485 return Ok(vec![]);
486 }
487
488 let placeholders = visited.iter().map(|_| "?").collect::<Vec<_>>().join(",");
490
491 let query = zeph_db::rewrite_placeholders(&format!(
492 "SELECT id, session_id, message_id, event_type, summary, created_at
493 FROM episodic_events
494 WHERE id IN ({placeholders}) AND session_id = ?
495 ORDER BY created_at ASC"
496 ));
497
498 let mut q = sqlx::query_as::<_, (i64, String, i64, String, String, i64)>(
499 sqlx::AssertSqlSafe(query),
500 );
501 for &id in &visited {
502 q = q.bind(id);
503 }
504 q = q.bind(session_id.as_str());
505
506 let rows = q.fetch_all(&pool).await?;
507
508 Ok(rows
509 .into_iter()
510 .map(
511 |(id, session_id, message_id, event_type, summary, created_at)| EpisodicEvent {
512 id,
513 session_id: SessionId::new(session_id),
514 message_id: MessageId(message_id),
515 event_type,
516 summary,
517 embedding: None,
518 created_at,
519 },
520 )
521 .collect())
522 }
523 .instrument(span)
524 .await
525}
526
527#[cfg(test)]
530mod tests {
531 use super::*;
532 use zeph_config::providers::ProviderName;
533
534 #[test]
535 fn parse_events_response_valid_json() {
536 let raw = r#"[{"event_type":"decision","summary":"User chose approach A"},{"event_type":"discovery","summary":"Found a bug in module X"}]"#;
537 let events = parse_events_response(raw, "sess-1", MessageId(42));
538 assert_eq!(events.len(), 2);
539 assert_eq!(events[0].event_type, "decision");
540 assert_eq!(events[1].summary, "Found a bug in module X");
541 assert_eq!(events[0].message_id, MessageId(42));
542 assert_eq!(events[0].session_id, "sess-1");
543 }
544
545 #[test]
546 fn parse_events_response_empty_array() {
547 let events = parse_events_response("[]", "sess-1", MessageId(1));
548 assert!(events.is_empty());
549 }
550
551 #[test]
552 fn parse_events_response_malformed_json() {
553 let events = parse_events_response("not json", "sess-1", MessageId(1));
554 assert!(events.is_empty());
555 }
556
557 #[test]
558 fn parse_events_response_skips_empty_summary() {
559 let raw = r#"[{"event_type":"decision","summary":""}]"#;
560 let events = parse_events_response(raw, "sess-1", MessageId(1));
561 assert!(events.is_empty(), "empty summary must be skipped");
562 }
563
564 #[test]
565 fn parse_links_response_valid_json() {
566 let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.8}]"#;
567 let links = parse_links_response(raw);
568 assert_eq!(links.len(), 1);
569 assert_eq!(links[0].cause_event_id, 1);
570 assert_eq!(links[0].effect_event_id, 2);
571 assert!((links[0].strength - 0.8).abs() < 0.01);
572 }
573
574 #[test]
575 fn parse_links_response_filters_weak_links() {
576 let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.3}]"#;
577 let links = parse_links_response(raw);
578 assert!(
579 links.is_empty(),
580 "weak links (strength < 0.5) must be filtered"
581 );
582 }
583
584 #[test]
585 fn parse_links_response_empty() {
586 let links = parse_links_response("[]");
587 assert!(links.is_empty());
588 }
589
590 #[test]
591 fn parse_links_response_malformed_json() {
592 let links = parse_links_response("not json");
593 assert!(links.is_empty());
594 }
595
596 #[test]
597 fn em_graph_config_defaults() {
598 let cfg = EmGraphConfig::default();
599 assert!(!cfg.enabled);
600 assert_eq!(cfg.max_chain_depth, 3);
601 }
602
603 #[tokio::test]
604 async fn store_and_fetch_events_in_memory_db() {
605 use crate::store::SqliteStore;
606
607 let store = SqliteStore::new(":memory:")
608 .await
609 .expect("SqliteStore::new");
610 let cid = store.create_conversation().await.expect("conversation");
611 let mid = store
612 .save_message(cid, "user", "hello world")
613 .await
614 .expect("save_message");
615
616 let mut events = vec![EpisodicEvent {
617 id: 0,
618 session_id: SessionId::new("test-session"),
619 message_id: mid,
620 event_type: "decision".to_owned(),
621 summary: "User decided to use approach A".to_owned(),
622 embedding: None,
623 created_at: 0,
624 }];
625
626 store_events(&store, &mut events)
627 .await
628 .expect("store_events");
629 assert!(events[0].id > 0, "id must be assigned after insert");
630
631 let fetched = fetch_recent_events(&store, "test-session", 10)
632 .await
633 .expect("fetch_recent_events");
634 assert_eq!(fetched.len(), 1);
635 assert_eq!(fetched[0].summary, "User decided to use approach A");
636 }
637
638 #[tokio::test]
639 async fn store_and_recall_causal_chain() {
640 use crate::store::SqliteStore;
641
642 let store = SqliteStore::new(":memory:")
643 .await
644 .expect("SqliteStore::new");
645 let cid = store.create_conversation().await.expect("conversation");
646 let mid = store
647 .save_message(cid, "user", "test")
648 .await
649 .expect("save_message");
650
651 let mut events = vec![
652 EpisodicEvent {
653 id: 0,
654 session_id: SessionId::new("sess"),
655 message_id: mid,
656 event_type: "discovery".to_owned(),
657 summary: "Found a bug".to_owned(),
658 embedding: None,
659 created_at: 0,
660 },
661 EpisodicEvent {
662 id: 0,
663 session_id: SessionId::new("sess"),
664 message_id: mid,
665 event_type: "decision".to_owned(),
666 summary: "Decided to fix it".to_owned(),
667 embedding: None,
668 created_at: 0,
669 },
670 ];
671 store_events(&store, &mut events)
672 .await
673 .expect("store_events");
674
675 let link = CausalLink {
676 id: 0,
677 cause_event_id: events[0].id,
678 effect_event_id: events[1].id,
679 strength: 0.9,
680 created_at: 0,
681 };
682 store_links(&store, &[link]).await.expect("store_links");
683
684 let config = EmGraphConfig {
685 enabled: true,
686 extract_provider: ProviderName::default(),
687 max_chain_depth: 3,
688 };
689 let chain = recall_episodic_causal(&store, events[0].id, "sess", 3, &config)
690 .await
691 .expect("recall_episodic_causal");
692
693 assert_eq!(
694 chain.len(),
695 2,
696 "chain must include seed and causally-linked event"
697 );
698 }
699
700 #[test]
701 fn parse_links_response_strength_at_boundary_included() {
702 let raw = r#"[{"cause_id":1,"effect_id":2,"strength":0.5}]"#;
704 let links = parse_links_response(raw);
705 assert_eq!(
706 links.len(),
707 1,
708 "strength=0.5 must be included (threshold is strict < 0.5)"
709 );
710 assert!((links[0].strength - 0.5).abs() < 0.001);
711 }
712
713 #[tokio::test]
714 async fn recall_episodic_causal_disabled_returns_empty() {
715 use crate::store::SqliteStore;
716
717 let store = SqliteStore::new(":memory:")
718 .await
719 .expect("SqliteStore::new");
720 let config = EmGraphConfig {
721 enabled: false,
722 extract_provider: ProviderName::default(),
723 max_chain_depth: 3,
724 };
725 let result = recall_episodic_causal(&store, 1, "sess", 3, &config).await;
726 assert!(result.is_ok());
727 assert!(
728 result.unwrap().is_empty(),
729 "disabled config must return empty"
730 );
731 }
732
733 #[tokio::test]
734 async fn store_links_is_idempotent_with_unique_constraint() {
735 use crate::store::SqliteStore;
736
737 let store = SqliteStore::new(":memory:")
738 .await
739 .expect("SqliteStore::new");
740 let cid = store.create_conversation().await.expect("conversation");
741 let mid = store
742 .save_message(cid, "user", "test")
743 .await
744 .expect("save_message");
745
746 let mut events = vec![
747 EpisodicEvent {
748 id: 0,
749 session_id: SessionId::new("sess"),
750 message_id: mid,
751 event_type: "decision".to_owned(),
752 summary: "A".to_owned(),
753 embedding: None,
754 created_at: 0,
755 },
756 EpisodicEvent {
757 id: 0,
758 session_id: SessionId::new("sess"),
759 message_id: mid,
760 event_type: "discovery".to_owned(),
761 summary: "B".to_owned(),
762 embedding: None,
763 created_at: 0,
764 },
765 ];
766 store_events(&store, &mut events)
767 .await
768 .expect("store_events");
769
770 let link = CausalLink {
771 id: 0,
772 cause_event_id: events[0].id,
773 effect_event_id: events[1].id,
774 strength: 0.8,
775 created_at: 0,
776 };
777 store_links(&store, std::slice::from_ref(&link))
779 .await
780 .expect("first store_links");
781 store_links(&store, &[link])
782 .await
783 .expect("second store_links (idempotent)");
784
785 let count: i64 = sqlx::query_scalar(
786 "SELECT COUNT(*) FROM causal_links WHERE cause_event_id = ? AND effect_event_id = ?",
787 )
788 .bind(events[0].id)
789 .bind(events[1].id)
790 .fetch_one(store.pool())
791 .await
792 .expect("count query");
793
794 assert_eq!(
795 count, 1,
796 "duplicate causal links must be deduplicated by UNIQUE constraint"
797 );
798 }
799}