1#![forbid(unsafe_code)]
10
11use async_trait::async_trait;
12
13use serde_json::{Value, json};
14use std::sync::Arc;
15use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
16use wm_memory::MemoryStore;
17
18use super::common::{galaxy_name, parse_galaxy};
19
20pub struct ReasoningBicameralTool {
28 store: Arc<MemoryStore>,
29 stats: ToolStats,
30 effects: EffectRow,
31}
32
33impl ReasoningBicameralTool {
34 pub fn new(store: Arc<MemoryStore>) -> Self {
35 Self {
36 store,
37 stats: ToolStats::default(),
38 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
39 }
40 }
41}
42
43#[async_trait]
44impl Tool for ReasoningBicameralTool {
45 fn name(&self) -> &str {
46 "reasoning.bicameral"
47 }
48 fn gana(&self) -> Gana {
49 Gana::ThreeStars
50 }
51 fn effects(&self) -> &EffectRow {
52 &self.effects
53 }
54 fn description(&self) -> &str {
55 "Analyze a topic from multiple perspectives using bicameral reasoning"
56 }
57 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
58 let topic = args
59 .get("topic")
60 .and_then(|v| v.as_str())
61 .ok_or_else(|| wm_core::CoreError::InvalidArgs("topic (string) required".into()))?;
62 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
63 let scan_limit = args
64 .get("scan_limit")
65 .and_then(serde_json::Value::as_u64)
66 .unwrap_or(500) as usize;
67
68 let galaxies: Vec<Galaxy> = match galaxy_str {
69 Some(g) => vec![parse_galaxy(g)?],
70 None => Galaxy::memory_galaxies().to_vec(),
71 };
72
73 let topic_lower = topic.to_lowercase();
74 let topic_words: Vec<&str> = topic_lower.split_whitespace().collect();
75
76 let mut supporting: Vec<Value> = Vec::new();
78 let mut opposing: Vec<Value> = Vec::new();
79 let mut neutral: Vec<Value> = Vec::new();
80
81 let opposition_markers = [
83 "however",
84 "but",
85 "against",
86 "con",
87 "negative",
88 "problem",
89 "issue",
90 "criticism",
91 "drawback",
92 "limitation",
93 "fail",
94 "wrong",
95 "disagree",
96 ];
97 let support_markers = [
98 "good",
99 "great",
100 "excellent",
101 "pro",
102 "positive",
103 "benefit",
104 "advantage",
105 "support",
106 "agree",
107 "correct",
108 "effective",
109 "success",
110 "strong",
111 ];
112
113 for galaxy in &galaxies {
114 let mems = self.store.scan(*galaxy, scan_limit)?;
115 for mem in mems {
116 if mem.metadata.model_exclude {
118 continue;
119 }
120 let content_lower = mem.content.to_lowercase();
121 if !topic_words.iter().any(|tw| content_lower.contains(tw)) {
122 continue;
123 }
124
125 let entry = json!({
126 "galaxy": galaxy_name(*galaxy),
127 "id": mem.metadata.id,
128 "content_preview": mem.content.chars().take(150).collect::<String>(),
129 "importance": mem.metadata.importance,
130 "tags": mem.metadata.tags,
131 });
132
133 let has_opposition = opposition_markers.iter().any(|m| content_lower.contains(m));
134 let has_support = support_markers.iter().any(|m| content_lower.contains(m));
135
136 if has_opposition && !has_support {
137 opposing.push(entry);
138 } else if has_support && !has_opposition {
139 supporting.push(entry);
140 } else {
141 neutral.push(entry);
142 }
143 }
144 }
145
146 let total = supporting.len() + opposing.len() + neutral.len();
147 let balance = if total == 0 {
148 0.0
149 } else {
150 ((supporting.len() as f64 - opposing.len() as f64) / total as f64 * 100.0).round()
151 };
152
153 let synthesis = if total == 0 {
155 format!(
156 "No memories found related to '{topic}'. Consider creating memories on this topic first."
157 )
158 } else if supporting.len() > opposing.len() * 2 {
159 format!(
160 "The evidence strongly favors '{}' with {} supporting vs {} opposing memories.",
161 topic,
162 supporting.len(),
163 opposing.len()
164 )
165 } else if opposing.len() > supporting.len() * 2 {
166 format!(
167 "The evidence predominantly opposes '{}' with {} opposing vs {} supporting memories.",
168 topic,
169 opposing.len(),
170 supporting.len()
171 )
172 } else {
173 format!(
174 "The evidence on '{}' is balanced: {} supporting, {} opposing, {} neutral. Further investigation recommended.",
175 topic,
176 supporting.len(),
177 opposing.len(),
178 neutral.len()
179 )
180 };
181
182 Ok(json!({
183 "status": "success",
184 "topic": topic,
185 "total_evidence": total,
186 "supporting": supporting,
187 "opposing": opposing,
188 "neutral": neutral,
189 "balance_score": balance,
190 "synthesis": synthesis,
191 }))
192 }
193 fn stats(&self) -> &ToolStats {
194 &self.stats
195 }
196}
197
198pub struct ThinkTool {
207 store: Arc<MemoryStore>,
208 stats: ToolStats,
209 effects: EffectRow,
210}
211
212impl ThinkTool {
213 pub fn new(store: Arc<MemoryStore>) -> Self {
214 Self {
215 store,
216 stats: ToolStats::default(),
217 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
218 }
219 }
220}
221
222#[async_trait]
223impl Tool for ThinkTool {
224 fn name(&self) -> &str {
225 "think"
226 }
227 fn gana(&self) -> Gana {
228 Gana::ThreeStars
229 }
230 fn effects(&self) -> &EffectRow {
231 &self.effects
232 }
233 fn description(&self) -> &str {
234 "Gather memory context and produce structured analysis with insights"
235 }
236 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
237 let query = args
238 .get("query")
239 .and_then(|v| v.as_str())
240 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
241 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
242 let depth = args
243 .get("depth")
244 .and_then(|v| v.as_str())
245 .unwrap_or("standard");
246 let max_memories = match depth {
247 "shallow" => 10usize,
248 "deep" => 100,
249 _ => 50,
250 };
251
252 let galaxies: Vec<Galaxy> = match galaxy_str {
253 Some(g) => vec![parse_galaxy(g)?],
254 None => Galaxy::memory_galaxies().to_vec(),
255 };
256
257 let query_lower = query.to_lowercase();
258 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
259
260 let mut relevant: Vec<(Galaxy, wm_memory::Memory, u32)> = Vec::new(); for galaxy in &galaxies {
264 let mems = self.store.scan(*galaxy, max_memories)?;
265 for mem in mems {
266 if mem.metadata.model_exclude {
268 continue;
269 }
270 let content_lower = mem.content.to_lowercase();
271 let mut score = 0u32;
272 for word in &query_words {
273 if content_lower.contains(word) {
274 score += 1;
275 }
276 }
277 for tag in &mem.metadata.tags {
279 if query_words.iter().any(|w| tag.contains(w)) {
280 score += 2;
281 }
282 }
283 if score > 0 {
284 relevant.push((*galaxy, mem, score));
285 }
286 }
287 }
288
289 relevant.sort_by(|a, b| {
291 b.2.cmp(&a.2).then_with(|| {
292 b.1.metadata
293 .importance
294 .partial_cmp(&a.1.metadata.importance)
295 .unwrap_or(std::cmp::Ordering::Equal)
296 })
297 });
298
299 let total_matches = relevant.len();
300 let top_memories: Vec<Value> = relevant
301 .iter()
302 .take(10)
303 .map(|(galaxy, mem, score)| {
304 json!({
305 "galaxy": galaxy_name(*galaxy),
306 "id": mem.metadata.id,
307 "content_preview": mem.content.chars().take(200).collect::<String>(),
308 "match_score": score,
309 "importance": mem.metadata.importance,
310 "tags": mem.metadata.tags,
311 })
312 })
313 .collect();
314
315 let mut theme_tags: std::collections::HashMap<String, u32> =
317 std::collections::HashMap::new();
318 for (_, mem, _) in &relevant {
319 for tag in &mem.metadata.tags {
320 *theme_tags.entry(tag.clone()).or_default() += 1;
321 }
322 }
323 let mut themes: Vec<(String, u32)> = theme_tags.into_iter().collect();
324 themes.sort_by_key(|x| std::cmp::Reverse(x.1));
325 let key_themes: Vec<String> = themes.iter().take(5).map(|(t, _)| t.clone()).collect();
326
327 let mut observations: Vec<String> = Vec::new();
329 if total_matches == 0 {
330 observations.push(format!(
331 "No existing memories match '{query}'. This appears to be a novel topic."
332 ));
333 } else {
334 observations.push(format!(
335 "Found {} relevant memories across {} galaxies.",
336 total_matches,
337 galaxies.len()
338 ));
339 if !key_themes.is_empty() {
340 observations.push(format!("Key themes: {}", key_themes.join(", ")));
341 }
342 let avg_importance: f32 = relevant
343 .iter()
344 .map(|(_, m, _)| m.metadata.importance)
345 .sum::<f32>()
346 / total_matches as f32;
347 observations.push(format!(
348 "Average importance of matched memories: {avg_importance:.2}"
349 ));
350 if total_matches > 20 {
351 observations
352 .push("High memory density — consider consolidation or synthesis.".into());
353 } else if total_matches < 3 {
354 observations.push(
355 "Low memory density — this topic may benefit from further exploration.".into(),
356 );
357 }
358 }
359
360 let questions: Vec<String> = if total_matches == 0 {
362 vec![format!("What existing knowledge relates to '{}'?", query)]
363 } else {
364 vec![
365 format!(
366 "What patterns emerge from these {} memories about '{}'?",
367 total_matches, query
368 ),
369 "Are there contradictions or gaps in the existing knowledge?".into(),
370 "What connections exist between these memories and other topics?".into(),
371 ]
372 };
373
374 Ok(json!({
375 "status": "success",
376 "query": query,
377 "depth": depth,
378 "total_matches": total_matches,
379 "key_themes": key_themes,
380 "observations": observations,
381 "questions": questions,
382 "relevant_memories": top_memories,
383 }))
384 }
385 fn stats(&self) -> &ToolStats {
386 &self.stats
387 }
388}
389
390pub struct ExplainTool {
397 store: Arc<MemoryStore>,
398 stats: ToolStats,
399 effects: EffectRow,
400}
401
402impl ExplainTool {
403 pub fn new(store: Arc<MemoryStore>) -> Self {
404 Self {
405 store,
406 stats: ToolStats::default(),
407 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
408 }
409 }
410}
411
412#[async_trait]
413impl Tool for ExplainTool {
414 fn name(&self) -> &str {
415 "explain"
416 }
417 fn gana(&self) -> Gana {
418 Gana::ThreeStars
419 }
420 fn effects(&self) -> &EffectRow {
421 &self.effects
422 }
423 fn description(&self) -> &str {
424 "Explain a memory or topic by gathering context from related memories"
425 }
426 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
427 let memory_id = args.get("memory_id").and_then(|v| v.as_str());
429 let topic = args.get("topic").and_then(|v| v.as_str());
430
431 if memory_id.is_none() && topic.is_none() {
432 return Err(wm_core::CoreError::InvalidArgs(
433 "Either memory_id (string) or topic (string) required".into(),
434 ));
435 }
436
437 let max_context = args
438 .get("max_context")
439 .and_then(serde_json::Value::as_u64)
440 .unwrap_or(10) as usize;
441
442 let mut target_memory: Option<(Galaxy, wm_memory::Memory)> = None;
444 if let Some(id_str) = memory_id {
445 let uuid = uuid::Uuid::parse_str(id_str)
446 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
447 for galaxy in Galaxy::memory_galaxies() {
448 if let Ok(Some(mem)) = self.store.get(galaxy, uuid) {
449 target_memory = Some((galaxy, mem));
450 break;
451 }
452 }
453 if target_memory.is_none() {
454 return Err(wm_core::CoreError::NotFound(format!(
455 "Memory {id_str} not found in any galaxy"
456 )));
457 }
458 }
459
460 let (search_text, target_info): (String, Option<Value>) = match &target_memory {
462 Some((galaxy, mem)) => (
463 mem.content.clone(),
464 Some(json!({
465 "galaxy": galaxy_name(*galaxy),
466 "id": mem.metadata.id,
467 "content": mem.content,
468 "importance": mem.metadata.importance,
469 "tags": mem.metadata.tags,
470 "created_at": mem.metadata.created_at.to_rfc3339(),
471 })),
472 ),
473 None => (topic.unwrap().to_string(), None),
474 };
475
476 let search_lower = search_text.to_lowercase();
477 let search_words: Vec<&str> = search_lower.split_whitespace().collect();
478
479 let mut related: Vec<(Galaxy, wm_memory::Memory, u32)> = Vec::new();
481 let target_id = target_memory.as_ref().map(|(_, m)| m.metadata.id);
482
483 for galaxy in Galaxy::all() {
484 let mems = self.store.scan(galaxy, 500)?;
485 for mem in mems {
486 if mem.metadata.model_exclude {
488 continue;
489 }
490 if Some(mem.metadata.id) == target_id {
492 continue;
493 }
494 let content_lower = mem.content.to_lowercase();
495 let mut score = 0u32;
496 for word in &search_words {
497 if content_lower.contains(word) {
498 score += 1;
499 }
500 }
501 if let Some((_, target_mem)) = &target_memory {
503 for tag in &mem.metadata.tags {
504 if target_mem.metadata.tags.contains(tag) {
505 score += 3;
506 }
507 }
508 }
509 if score > 0 {
510 related.push((galaxy, mem, score));
511 }
512 }
513 }
514
515 related.sort_by_key(|x| std::cmp::Reverse(x.2));
516 let total_related = related.len();
517
518 let context_memories: Vec<Value> = related
519 .iter()
520 .take(max_context)
521 .map(|(galaxy, mem, score)| {
522 json!({
523 "galaxy": galaxy_name(*galaxy),
524 "id": mem.metadata.id,
525 "content_preview": mem.content.chars().take(150).collect::<String>(),
526 "relevance_score": score,
527 "tags": mem.metadata.tags,
528 })
529 })
530 .collect();
531
532 let summary = if total_related == 0 {
534 "This memory/topic appears to be isolated with no related memories. Consider creating connections.".into()
535 } else {
536 let galaxies_involved: std::collections::HashSet<&str> = related
537 .iter()
538 .take(max_context)
539 .map(|(g, _, _)| galaxy_name(*g))
540 .collect();
541 format!(
542 "Found {} related memories across {} galaxies. The topic connects to multiple knowledge areas.",
543 total_related,
544 galaxies_involved.len()
545 )
546 };
547
548 Ok(json!({
549 "status": "success",
550 "target": target_info,
551 "topic": topic,
552 "total_related": total_related,
553 "context_memories": context_memories,
554 "summary": summary,
555 }))
556 }
557 fn stats(&self) -> &ToolStats {
558 &self.stats
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
567 let tmp = tempfile::tempdir().unwrap();
568 let store = MemoryStore::open_default(tmp.path()).unwrap();
569 (tmp, Arc::new(store))
570 }
571
572 fn seed_memories(store: &Arc<MemoryStore>) -> Vec<uuid::Uuid> {
573 let mut ids = Vec::new();
574 let entries = [
575 (
576 Galaxy::Codex,
577 "Rust is a great systems programming language with memory safety",
578 vec!["rust", "programming"],
579 0.9,
580 ),
581 (
582 Galaxy::Codex,
583 "However Rust has a steep learning curve for beginners",
584 vec!["rust", "programming"],
585 0.6,
586 ),
587 (
588 Galaxy::Research,
589 "Python is excellent for data science and rapid prototyping",
590 vec!["python", "data"],
591 0.8,
592 ),
593 (
594 Galaxy::Research,
595 "But Python performance is a problem for compute-intensive tasks",
596 vec!["python", "performance"],
597 0.5,
598 ),
599 (
600 Galaxy::Codex,
601 "Rust ownership model prevents memory leaks at compile time",
602 vec!["rust", "memory"],
603 0.85,
604 ),
605 (
606 Galaxy::Tutorial,
607 "Rust traits enable polymorphism without runtime overhead",
608 vec!["rust", "traits"],
609 0.7,
610 ),
611 ];
612
613 for (galaxy, content, tags, imp) in entries {
614 let mut mem = wm_memory::Memory::new(galaxy, content.into());
615 mem.metadata.tags = tags.iter().map(std::string::ToString::to_string).collect();
616 mem.metadata.importance = imp;
617 ids.push(mem.metadata.id);
618 store.put(galaxy, &mem).unwrap();
619 }
620 ids
621 }
622
623 #[tokio::test]
624 async fn bicameral_finds_supporting_and_opposing() {
625 let (_tmp, store) = open_store();
626 seed_memories(&store);
627
628 let tool = ReasoningBicameralTool::new(store);
629 let result = tool
630 .call(&mut Context::default(), json!({"topic": "rust"}))
631 .await
632 .unwrap();
633 let obj = result.as_object().unwrap();
634 assert_eq!(obj["status"], "success");
635 assert!(obj["total_evidence"].as_u64().unwrap() >= 4);
636 let supporting = obj["supporting"].as_array().unwrap();
637 let opposing = obj["opposing"].as_array().unwrap();
638 assert!(!supporting.is_empty() || !opposing.is_empty());
639 assert!(obj["synthesis"].as_str().unwrap().contains("rust"));
640 }
641
642 #[tokio::test]
643 async fn bicameral_no_matches() {
644 let (_tmp, store) = open_store();
645 let tool = ReasoningBicameralTool::new(store);
646 let result = tool
647 .call(&mut Context::default(), json!({"topic": "nonexistent"}))
648 .await
649 .unwrap();
650 let obj = result.as_object().unwrap();
651 assert_eq!(obj["total_evidence"], 0);
652 assert!(obj["synthesis"].as_str().unwrap().contains("No memories"));
653 }
654
655 #[tokio::test]
656 async fn bicameral_missing_topic() {
657 let (_tmp, store) = open_store();
658 let tool = ReasoningBicameralTool::new(store);
659 let result = tool.call(&mut Context::default(), json!({})).await;
660 assert!(result.is_err());
661 }
662
663 #[tokio::test]
664 async fn think_gathers_context() {
665 let (_tmp, store) = open_store();
666 seed_memories(&store);
667
668 let tool = ThinkTool::new(store);
669 let result = tool
670 .call(
671 &mut Context::default(),
672 json!({"query": "rust programming", "depth": "standard"}),
673 )
674 .await
675 .unwrap();
676 let obj = result.as_object().unwrap();
677 assert_eq!(obj["status"], "success");
678 assert!(obj["total_matches"].as_u64().unwrap() >= 4);
679 let themes = obj["key_themes"].as_array().unwrap();
680 assert!(!themes.is_empty());
681 let observations = obj["observations"].as_array().unwrap();
682 assert!(!observations.is_empty());
683 let questions = obj["questions"].as_array().unwrap();
684 assert!(!questions.is_empty());
685 }
686
687 #[tokio::test]
688 async fn think_shallow_depth() {
689 let (_tmp, store) = open_store();
690 seed_memories(&store);
691
692 let tool = ThinkTool::new(store);
693 let result = tool
694 .call(
695 &mut Context::default(),
696 json!({"query": "rust", "depth": "shallow"}),
697 )
698 .await
699 .unwrap();
700 let obj = result.as_object().unwrap();
701 assert_eq!(obj["depth"], "shallow");
702 }
703
704 #[tokio::test]
705 async fn think_missing_query() {
706 let (_tmp, store) = open_store();
707 let tool = ThinkTool::new(store);
708 let result = tool.call(&mut Context::default(), json!({})).await;
709 assert!(result.is_err());
710 }
711
712 #[tokio::test]
713 async fn model_excluded_memories_never_enter_evidence() {
714 let (_tmp, store) = open_store();
715 seed_memories(&store);
716 let mut excluded = wm_memory::Memory::new(
718 Galaxy::Codex,
719 "rust programming secret internal design".to_string(),
720 );
721 excluded.metadata.model_exclude = true;
722 excluded.metadata.importance = 1.0;
723 store.put(Galaxy::Codex, &excluded).unwrap();
724
725 let tool = ThinkTool::new(store);
726 let result = tool
727 .call(
728 &mut Context::default(),
729 json!({"query": "rust programming", "depth": "standard"}),
730 )
731 .await
732 .unwrap();
733 let obj = result.as_object().unwrap();
734 let top = obj["relevant_memories"].as_array().unwrap();
735 for m in top {
736 let preview = m["content_preview"].as_str().unwrap_or("");
737 assert!(
738 !preview.contains("secret"),
739 "model_exclude memory leaked into reasoning evidence: {m}"
740 );
741 }
742 }
743
744 #[tokio::test]
745 async fn explain_by_memory_id() {
746 let (_tmp, store) = open_store();
747 let ids = seed_memories(&store);
748
749 let tool = ExplainTool::new(store);
750 let result = tool
751 .call(
752 &mut Context::default(),
753 json!({"memory_id": ids[0].to_string()}),
754 )
755 .await
756 .unwrap();
757 let obj = result.as_object().unwrap();
758 assert_eq!(obj["status"], "success");
759 assert!(obj["target"].is_object());
760 assert!(obj["total_related"].as_u64().unwrap() >= 3);
761 assert!(
762 obj["summary"]
763 .as_str()
764 .unwrap()
765 .contains("related memories")
766 );
767 }
768
769 #[tokio::test]
770 async fn explain_by_topic() {
771 let (_tmp, store) = open_store();
772 seed_memories(&store);
773
774 let tool = ExplainTool::new(store);
775 let result = tool
776 .call(
777 &mut Context::default(),
778 json!({"topic": "rust memory safety"}),
779 )
780 .await
781 .unwrap();
782 let obj = result.as_object().unwrap();
783 assert_eq!(obj["status"], "success");
784 assert!(obj["total_related"].as_u64().unwrap() >= 2);
785 }
786
787 #[tokio::test]
788 async fn explain_missing_args() {
789 let (_tmp, store) = open_store();
790 let tool = ExplainTool::new(store);
791 let result = tool.call(&mut Context::default(), json!({})).await;
792 assert!(result.is_err());
793 }
794
795 #[tokio::test]
796 async fn explain_invalid_uuid() {
797 let (_tmp, store) = open_store();
798 let tool = ExplainTool::new(store);
799 let result = tool
800 .call(&mut Context::default(), json!({"memory_id": "not-a-uuid"}))
801 .await;
802 assert!(result.is_err());
803 }
804
805 #[tokio::test]
806 async fn explain_not_found() {
807 let (_tmp, store) = open_store();
808 let tool = ExplainTool::new(store);
809 let result = tool
810 .call(
811 &mut Context::default(),
812 json!({"memory_id": "00000000-0000-0000-0000-000000000000"}),
813 )
814 .await;
815 assert!(result.is_err());
816 }
817}