1use std::pin::Pin;
13
14use zeph_common::memory::{
15 AsyncMemoryRouter, ContextMemoryBackend, GraphRecallParams, MemCorrection, MemDocumentChunk,
16 MemGraphFact, MemGraphNeighbor, MemPersonaFact, MemReasoningStrategy, MemRecalledMessage,
17 MemSessionSummary, MemSummary, MemTrajectoryEntry, MemTreeNode, RecallView,
18};
19use zeph_memory::semantic::SemanticMemory;
20use zeph_memory::{ConversationId, RecallView as MemRecallView, RecalledFact};
21
22fn box_err<E: std::error::Error + Send + Sync + 'static>(
23 e: E,
24) -> Box<dyn std::error::Error + Send + Sync> {
25 Box::new(e)
26}
27
28fn map_persona_fact(r: zeph_memory::PersonaFactRow) -> MemPersonaFact {
29 MemPersonaFact {
30 category: r.category,
31 content: r.content,
32 }
33}
34
35fn map_trajectory_entry(r: zeph_memory::TrajectoryEntryRow) -> MemTrajectoryEntry {
36 MemTrajectoryEntry {
37 intent: r.intent,
38 outcome: r.outcome,
39 confidence: r.confidence,
40 }
41}
42
43fn map_tree_node(r: zeph_memory::MemoryTreeRow) -> MemTreeNode {
44 MemTreeNode { content: r.content }
45}
46
47fn map_summary(r: zeph_memory::semantic::Summary) -> MemSummary {
48 MemSummary {
49 first_message_id: r.first_message_id.map(|m| m.0),
50 last_message_id: r.last_message_id.map(|m| m.0),
51 content: r.content,
52 }
53}
54
55fn map_reasoning_strategy(s: zeph_memory::ReasoningStrategy) -> MemReasoningStrategy {
56 MemReasoningStrategy {
57 id: s.id,
58 outcome: s.outcome.as_str().to_owned(),
59 summary: s.summary,
60 }
61}
62
63fn map_correction(c: zeph_memory::UserCorrectionRow) -> MemCorrection {
64 MemCorrection {
65 correction_text: c.correction_text,
66 }
67}
68
69fn map_recalled_message(r: zeph_memory::RecalledMessage) -> MemRecalledMessage {
70 use zeph_llm::provider::Role;
71 let role = match r.message.role {
72 Role::User => "user",
73 Role::Assistant => "assistant",
74 Role::System => "system",
75 }
76 .to_owned();
77 MemRecalledMessage {
78 role,
79 content: r.message.content,
80 score: r.score,
81 }
82}
83
84fn map_graph_fact(rf: RecalledFact) -> MemGraphFact {
85 MemGraphFact {
86 fact: rf.fact.fact,
87 confidence: rf.fact.confidence,
88 activation_score: rf.activation_score,
89 neighbors: rf
90 .neighbors
91 .into_iter()
92 .map(|n| MemGraphNeighbor {
93 fact: n.fact,
94 confidence: n.confidence,
95 })
96 .collect(),
97 provenance_snippet: rf.provenance_snippet,
98 }
99}
100
101fn map_session_summary(r: zeph_memory::semantic::SessionSummaryResult) -> MemSessionSummary {
102 MemSessionSummary {
103 summary_text: r.summary_text,
104 score: r.score,
105 }
106}
107
108pub struct SemanticMemoryBackend {
110 inner: std::sync::Arc<SemanticMemory>,
111}
112
113impl SemanticMemoryBackend {
114 #[must_use]
116 pub fn new(inner: std::sync::Arc<SemanticMemory>) -> Self {
117 Self { inner }
118 }
119}
120
121type BoxFut<'a, T> = Pin<
122 Box<
123 dyn std::future::Future<Output = Result<T, Box<dyn std::error::Error + Send + Sync>>>
124 + Send
125 + 'a,
126 >,
127>;
128
129impl ContextMemoryBackend for SemanticMemoryBackend {
130 fn load_persona_facts(&self, min_confidence: f64) -> BoxFut<'_, Vec<MemPersonaFact>> {
131 Box::pin(async move {
132 let rows = self
133 .inner
134 .sqlite()
135 .load_persona_facts(min_confidence)
136 .await
137 .map_err(box_err)?;
138 Ok(rows.into_iter().map(map_persona_fact).collect())
139 })
140 }
141
142 fn load_trajectory_entries<'a>(
143 &'a self,
144 tier: Option<&'a str>,
145 top_k: usize,
146 ) -> BoxFut<'a, Vec<MemTrajectoryEntry>> {
147 Box::pin(async move {
148 let rows = self
149 .inner
150 .sqlite()
151 .load_trajectory_entries(tier, top_k)
152 .await
153 .map_err(box_err)?;
154 Ok(rows.into_iter().map(map_trajectory_entry).collect())
155 })
156 }
157
158 fn load_tree_nodes(&self, level: u32, top_k: usize) -> BoxFut<'_, Vec<MemTreeNode>> {
159 Box::pin(async move {
160 let rows = self
161 .inner
162 .sqlite()
163 .load_tree_level(level.into(), top_k)
164 .await
165 .map_err(box_err)?;
166 Ok(rows.into_iter().map(map_tree_node).collect())
167 })
168 }
169
170 fn load_summaries(&self, conversation_id: i64) -> BoxFut<'_, Vec<MemSummary>> {
171 Box::pin(async move {
172 let cid = ConversationId(conversation_id);
173 let rows = self.inner.load_summaries(cid).await.map_err(box_err)?;
174 Ok(rows.into_iter().map(map_summary).collect())
175 })
176 }
177
178 fn retrieve_reasoning_strategies<'a>(
179 &'a self,
180 query: &'a str,
181 top_k: usize,
182 ) -> BoxFut<'a, Vec<MemReasoningStrategy>> {
183 Box::pin(async move {
184 let strategies = self
185 .inner
186 .retrieve_reasoning_strategies(query, top_k)
187 .await
188 .map_err(box_err)?;
189 Ok(strategies.into_iter().map(map_reasoning_strategy).collect())
190 })
191 }
192
193 fn mark_reasoning_used<'a>(&'a self, ids: &'a [String]) -> BoxFut<'a, ()> {
194 Box::pin(async move {
195 if let Some(ref reasoning) = self.inner.reasoning {
196 reasoning.mark_used(ids).await.map_err(box_err)?;
197 }
198 Ok(())
199 })
200 }
201
202 fn retrieve_corrections<'a>(
203 &'a self,
204 query: &'a str,
205 limit: usize,
206 min_score: f32,
207 ) -> BoxFut<'a, Vec<MemCorrection>> {
208 Box::pin(async move {
209 let corrections = self
210 .inner
211 .retrieve_similar_corrections(query, limit, min_score)
212 .await
213 .map_err(box_err)?;
214 Ok(corrections.into_iter().map(map_correction).collect())
215 })
216 }
217
218 fn recall<'a>(
219 &'a self,
220 query: &'a str,
221 limit: usize,
222 router: Option<&'a dyn AsyncMemoryRouter>,
223 ) -> BoxFut<'a, Vec<MemRecalledMessage>> {
224 Box::pin(async move {
225 let recalled = if let Some(r) = router {
226 self.inner
227 .recall_routed_async(query, limit, None, r, None)
228 .await
229 .map_err(box_err)?
230 } else {
231 self.inner
232 .recall(query, limit, None)
233 .await
234 .map_err(box_err)?
235 };
236 Ok(recalled.into_iter().map(map_recalled_message).collect())
237 })
238 }
239
240 fn recall_graph_facts<'a>(
241 &'a self,
242 query: &'a str,
243 params: GraphRecallParams<'a>,
244 ) -> BoxFut<'a, Vec<MemGraphFact>> {
245 Box::pin(async move {
246 let mem_view = match params.view {
247 RecallView::ZoomIn => MemRecallView::ZoomIn,
248 RecallView::ZoomOut => MemRecallView::ZoomOut,
249 _ => MemRecallView::Head,
250 };
251 let mem_edge_types: Vec<zeph_memory::EdgeType> = params
252 .edge_types
253 .iter()
254 .map(|e| {
255 use zeph_common::memory::EdgeType as CE;
256 use zeph_memory::EdgeType as ME;
257 match e {
258 CE::Temporal => ME::Temporal,
259 CE::Causal => ME::Causal,
260 CE::Entity => ME::Entity,
261 _ => ME::Semantic,
262 }
263 })
264 .collect();
265 let sa_params = params.spreading_activation.map(|p| {
266 zeph_memory::graph::SpreadingActivationParams {
267 decay_lambda: p.decay_lambda,
268 max_hops: p.max_hops,
269 activation_threshold: p.activation_threshold,
270 inhibition_threshold: p.inhibition_threshold,
271 max_activated_nodes: p.max_activated_nodes,
272 temporal_decay_rate: p.temporal_decay_rate,
273 seed_structural_weight: p.seed_structural_weight,
274 seed_community_cap: p.seed_community_cap,
275 }
276 });
277 let recalled = self
278 .inner
279 .recall_graph_view(
280 query,
281 params.limit,
282 mem_view,
283 params.zoom_out_neighbor_cap,
284 params.max_hops,
285 params.temporal_decay_rate,
286 &mem_edge_types,
287 sa_params,
288 )
289 .await
290 .map_err(box_err)?;
291 Ok(recalled.into_iter().map(map_graph_fact).collect())
292 })
293 }
294
295 fn search_session_summaries<'a>(
296 &'a self,
297 query: &'a str,
298 limit: usize,
299 current_conversation_id: Option<i64>,
300 ) -> BoxFut<'a, Vec<MemSessionSummary>> {
301 Box::pin(async move {
302 let cid = current_conversation_id.map(ConversationId);
303 let results = self
304 .inner
305 .search_session_summaries(query, limit, cid)
306 .await
307 .map_err(box_err)?;
308 Ok(results.into_iter().map(map_session_summary).collect())
309 })
310 }
311
312 fn search_document_collection<'a>(
313 &'a self,
314 collection: &'a str,
315 query: &'a str,
316 top_k: usize,
317 ) -> BoxFut<'a, Vec<MemDocumentChunk>> {
318 Box::pin(async move {
319 let points = self
320 .inner
321 .search_document_collection(collection, query, top_k)
322 .await
323 .map_err(box_err)?;
324 Ok(points
325 .into_iter()
326 .map(|p| {
327 let text = p
328 .payload
329 .get("text")
330 .and_then(|v| v.as_str())
331 .unwrap_or_default()
332 .to_owned();
333 MemDocumentChunk { text }
334 })
335 .collect())
336 })
337 }
338}
339
340pub struct TokenCounterAdapter(std::sync::Arc<zeph_memory::TokenCounter>);
343
344impl TokenCounterAdapter {
345 #[must_use]
347 pub fn new(inner: std::sync::Arc<zeph_memory::TokenCounter>) -> Self {
348 Self(inner)
349 }
350}
351
352impl zeph_context::summarization::MessageTokenCounter for TokenCounterAdapter {
353 fn count_message_tokens(&self, msg: &zeph_llm::provider::Message) -> usize {
354 self.0.count_message_tokens(msg)
355 }
356}
357
358#[must_use]
366pub fn build_memory_router(
367 manager: &zeph_context::manager::ContextManager,
368) -> Box<dyn zeph_common::memory::AsyncMemoryRouter + Send + Sync> {
369 use zeph_config::StoreRoutingStrategy;
370
371 if !manager.routing.enabled {
372 return Box::new(zeph_memory::HeuristicRouter);
373 }
374 let fallback = manager.routing.fallback_route;
375 match manager.routing.strategy {
376 StoreRoutingStrategy::Llm => {
377 let Some(provider) = manager.store_routing_provider.clone() else {
378 tracing::warn!(
379 "store_routing: strategy=llm but no provider resolved; \
380 falling back to heuristic"
381 );
382 return Box::new(zeph_memory::HeuristicRouter);
383 };
384 Box::new(zeph_memory::LlmRouter::new(provider, fallback))
385 }
386 StoreRoutingStrategy::Hybrid => {
387 let Some(provider) = manager.store_routing_provider.clone() else {
388 tracing::warn!(
389 "store_routing: strategy=hybrid but no provider resolved; \
390 falling back to heuristic"
391 );
392 return Box::new(zeph_memory::HeuristicRouter);
393 };
394 Box::new(zeph_memory::HybridRouter::new(
395 provider,
396 fallback,
397 manager.routing.confidence_threshold,
398 ))
399 }
400 _ => Box::new(zeph_memory::HeuristicRouter),
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use zeph_llm::provider::{Message, Role};
407 use zeph_memory::graph::types::{EdgeType, GraphFact};
408 use zeph_memory::semantic::{SessionSummaryResult, Summary};
409 use zeph_memory::types::{ConversationId, MessageId};
410 use zeph_memory::{
411 MemoryTreeRow, Outcome, PersonaFactRow, ReasoningStrategy, RecalledFact, RecalledMessage,
412 TrajectoryEntryRow, UserCorrectionRow,
413 };
414
415 use super::*;
416
417 fn make_persona_row() -> PersonaFactRow {
418 PersonaFactRow {
419 id: 1,
420 category: "preference".to_owned(),
421 content: "prefers short answers".to_owned(),
422 confidence: 0.9,
423 evidence_count: 3,
424 source_conversation_id: None,
425 supersedes_id: None,
426 created_at: "2026-01-01".to_owned(),
427 updated_at: "2026-01-02".to_owned(),
428 }
429 }
430
431 fn make_trajectory_row() -> TrajectoryEntryRow {
432 TrajectoryEntryRow {
433 id: 1,
434 conversation_id: Some(42),
435 turn_index: 5,
436 kind: "procedural".to_owned(),
437 intent: "read a file".to_owned(),
438 outcome: "file read successfully".to_owned(),
439 tools_used: "read_file".to_owned(),
440 confidence: 0.85,
441 created_at: "2026-01-01".to_owned(),
442 updated_at: "2026-01-01".to_owned(),
443 }
444 }
445
446 fn make_tree_row() -> MemoryTreeRow {
447 MemoryTreeRow {
448 id: 1,
449 level: 0,
450 parent_id: None,
451 content: "node content here".to_owned(),
452 source_ids: "1,2,3".to_owned(),
453 token_count: 10,
454 consolidated_at: None,
455 created_at: "2026-01-01".to_owned(),
456 }
457 }
458
459 fn make_summary() -> Summary {
460 Summary {
461 id: 1,
462 conversation_id: ConversationId(10),
463 content: "summary of the conversation".to_owned(),
464 first_message_id: Some(MessageId(5)),
465 last_message_id: Some(MessageId(20)),
466 token_estimate: 100,
467 }
468 }
469
470 fn make_reasoning_strategy() -> ReasoningStrategy {
471 ReasoningStrategy {
472 id: "strat-uuid-1".to_owned(),
473 summary: "break the problem into parts".to_owned(),
474 outcome: Outcome::Success,
475 task_hint: "code refactoring task".to_owned(),
476 created_at: 1_700_000_000,
477 last_used_at: 1_700_000_100,
478 use_count: 3,
479 embedded_at: Some(1_700_000_050),
480 }
481 }
482
483 fn make_correction_row() -> UserCorrectionRow {
484 UserCorrectionRow {
485 id: 1,
486 session_id: Some(7),
487 original_output: "wrong output".to_owned(),
488 correction_text: "use bullet points".to_owned(),
489 skill_name: Some("formatting".to_owned()),
490 correction_kind: "explicit_rejection".to_owned(),
491 created_at: "2026-01-01".to_owned(),
492 }
493 }
494
495 fn make_recalled_message(role: Role) -> RecalledMessage {
496 RecalledMessage {
497 message: Message {
498 role,
499 content: "hello world".to_owned(),
500 ..Default::default()
501 },
502 score: 0.75,
503 }
504 }
505
506 fn make_graph_fact() -> GraphFact {
507 GraphFact {
508 entity_name: "Rust".to_owned(),
509 relation: "uses".to_owned(),
510 target_name: "LLVM".to_owned(),
511 fact: "Rust uses LLVM".to_owned(),
512 entity_match_score: 0.9,
513 hop_distance: 0,
514 confidence: 0.95,
515 valid_from: None,
516 edge_type: EdgeType::Semantic,
517 retrieval_count: 1,
518 edge_id: Some(10),
519 }
520 }
521
522 fn make_recalled_fact() -> RecalledFact {
523 RecalledFact::from_graph_fact(make_graph_fact())
524 }
525
526 fn make_session_summary() -> SessionSummaryResult {
527 SessionSummaryResult {
528 summary_text: "yesterday's session about Rust".to_owned(),
529 score: 0.88,
530 conversation_id: ConversationId(99),
531 }
532 }
533
534 #[test]
537 fn persona_fact_maps_fields() {
538 let row = make_persona_row();
539 let dto = map_persona_fact(row);
540 assert_eq!(dto.category, "preference");
541 assert_eq!(dto.content, "prefers short answers");
542 }
543
544 #[test]
547 fn trajectory_entry_maps_fields() {
548 let row = make_trajectory_row();
549 let dto = map_trajectory_entry(row);
550 assert_eq!(dto.intent, "read a file");
551 assert_eq!(dto.outcome, "file read successfully");
552 assert!((dto.confidence - 0.85).abs() < f64::EPSILON);
553 }
554
555 #[test]
558 fn tree_node_maps_content() {
559 let row = make_tree_row();
560 let dto = map_tree_node(row);
561 assert_eq!(dto.content, "node content here");
562 }
563
564 #[test]
567 fn summary_maps_all_fields() {
568 let s = make_summary();
569 let dto = map_summary(s);
570 assert_eq!(dto.first_message_id, Some(5));
571 assert_eq!(dto.last_message_id, Some(20));
572 assert_eq!(dto.content, "summary of the conversation");
573 }
574
575 #[test]
576 fn summary_none_message_ids_stay_none() {
577 let s = Summary {
578 id: 2,
579 conversation_id: ConversationId(1),
580 content: "shutdown summary".to_owned(),
581 first_message_id: None,
582 last_message_id: None,
583 token_estimate: 50,
584 };
585 let dto = map_summary(s);
586 assert!(dto.first_message_id.is_none());
587 assert!(dto.last_message_id.is_none());
588 }
589
590 #[test]
593 fn reasoning_strategy_maps_success_outcome() {
594 let s = make_reasoning_strategy();
595 let dto = map_reasoning_strategy(s);
596 assert_eq!(dto.id, "strat-uuid-1");
597 assert_eq!(dto.outcome, "success");
598 assert_eq!(dto.summary, "break the problem into parts");
599 }
600
601 #[test]
602 fn reasoning_strategy_maps_failure_outcome() {
603 let mut s = make_reasoning_strategy();
604 s.outcome = Outcome::Failure;
605 let dto = map_reasoning_strategy(s);
606 assert_eq!(dto.outcome, "failure");
607 }
608
609 #[test]
612 fn correction_maps_text() {
613 let row = make_correction_row();
614 let dto = map_correction(row);
615 assert_eq!(dto.correction_text, "use bullet points");
616 }
617
618 #[test]
621 fn recalled_message_maps_user_role() {
622 let rm = make_recalled_message(Role::User);
623 let dto = map_recalled_message(rm);
624 assert_eq!(dto.role, "user");
625 assert_eq!(dto.content, "hello world");
626 assert!((dto.score - 0.75).abs() < f32::EPSILON);
627 }
628
629 #[test]
630 fn recalled_message_maps_assistant_role() {
631 let rm = make_recalled_message(Role::Assistant);
632 let dto = map_recalled_message(rm);
633 assert_eq!(dto.role, "assistant");
634 assert!((dto.score - 0.75).abs() < f32::EPSILON);
635 }
636
637 #[test]
638 fn recalled_message_maps_system_role() {
639 let rm = make_recalled_message(Role::System);
640 let dto = map_recalled_message(rm);
641 assert_eq!(dto.role, "system");
642 assert!((dto.score - 0.75).abs() < f32::EPSILON);
643 }
644
645 #[test]
648 fn graph_fact_maps_basic_fields() {
649 let rf = make_recalled_fact();
650 let dto = map_graph_fact(rf);
651 assert_eq!(dto.fact, "Rust uses LLVM");
652 assert!((dto.confidence - 0.95).abs() < f32::EPSILON);
653 assert!(dto.activation_score.is_none());
654 assert!(dto.neighbors.is_empty());
655 assert!(dto.provenance_snippet.is_none());
656 }
657
658 #[test]
659 fn graph_fact_maps_activation_score() {
660 let mut rf = make_recalled_fact();
661 rf.activation_score = Some(0.82);
662 let dto = map_graph_fact(rf);
663 assert!(
664 dto.activation_score
665 .is_some_and(|s| (s - 0.82_f32).abs() < f32::EPSILON)
666 );
667 }
668
669 #[test]
670 fn graph_fact_maps_neighbors() {
671 let mut rf = make_recalled_fact();
672 rf.neighbors.push(GraphFact {
673 entity_name: "LLVM".to_owned(),
674 relation: "supports".to_owned(),
675 target_name: "WebAssembly".to_owned(),
676 fact: "LLVM supports WebAssembly".to_owned(),
677 entity_match_score: 0.5,
678 hop_distance: 1,
679 confidence: 0.8,
680 valid_from: None,
681 edge_type: EdgeType::Semantic,
682 retrieval_count: 0,
683 edge_id: None,
684 });
685 let dto = map_graph_fact(rf);
686 assert_eq!(dto.neighbors.len(), 1);
687 assert_eq!(dto.neighbors[0].fact, "LLVM supports WebAssembly");
688 assert!((dto.neighbors[0].confidence - 0.8).abs() < f32::EPSILON);
689 }
690
691 #[test]
692 fn graph_fact_maps_provenance_snippet() {
693 let mut rf = make_recalled_fact();
694 rf.provenance_snippet = Some("Rust compiler snippet".to_owned());
695 let dto = map_graph_fact(rf);
696 assert_eq!(
697 dto.provenance_snippet.as_deref(),
698 Some("Rust compiler snippet")
699 );
700 }
701
702 #[test]
705 fn session_summary_maps_fields() {
706 let r = make_session_summary();
707 let dto = map_session_summary(r);
708 assert_eq!(dto.summary_text, "yesterday's session about Rust");
709 assert!((dto.score - 0.88).abs() < f32::EPSILON);
710 }
711
712 #[test]
713 fn session_summary_score_zero() {
714 let r = SessionSummaryResult {
715 summary_text: "empty session".to_owned(),
716 score: 0.0,
717 conversation_id: ConversationId(1),
718 };
719 let dto = map_session_summary(r);
720 assert!(dto.score.abs() < f32::EPSILON);
721 }
722
723 #[test]
724 fn session_summary_score_one() {
725 let r = SessionSummaryResult {
726 summary_text: "perfect match".to_owned(),
727 score: 1.0,
728 conversation_id: ConversationId(1),
729 };
730 let dto = map_session_summary(r);
731 assert!((dto.score - 1.0_f32).abs() < f32::EPSILON);
732 }
733}