1#![forbid(unsafe_code)]
9
10use async_trait::async_trait;
11
12use serde_json::{Value, json};
13use std::collections::HashMap;
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 ArchaeologySearchTool {
28 store: Arc<MemoryStore>,
29 stats: ToolStats,
30 effects: EffectRow,
31}
32
33impl ArchaeologySearchTool {
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 ArchaeologySearchTool {
45 fn name(&self) -> &str {
46 "archaeology.search"
47 }
48 fn gana(&self) -> Gana {
49 Gana::Ox
50 }
51 fn effects(&self) -> &EffectRow {
52 &self.effects
53 }
54 fn description(&self) -> &str {
55 "Excavate memory layers by time depth and importance stratification"
56 }
57 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
58 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
59 let keyword = args.get("keyword").and_then(|v| v.as_str()).unwrap_or("");
60 let max_layers = args
61 .get("max_layers")
62 .and_then(serde_json::Value::as_u64)
63 .unwrap_or(5) as usize;
64 let memories_per_layer = args
65 .get("memories_per_layer")
66 .and_then(serde_json::Value::as_u64)
67 .unwrap_or(5) as usize;
68
69 let galaxies: Vec<Galaxy> = match galaxy_str {
70 Some(g) => vec![parse_galaxy(g)?],
71 None => Galaxy::memory_galaxies().to_vec(),
72 };
73
74 let mut all_mems: Vec<(Galaxy, wm_memory::Memory)> = Vec::new();
76 for galaxy in &galaxies {
77 let mems = self.store.scan(*galaxy, 1000)?;
78 for mem in mems {
79 if keyword.is_empty()
80 || mem.content.to_lowercase().contains(&keyword.to_lowercase())
81 {
82 all_mems.push((*galaxy, mem));
83 }
84 }
85 }
86
87 if all_mems.is_empty() {
88 return Ok(json!({
89 "status": "success",
90 "total_memories": 0,
91 "layers": [],
92 }));
93 }
94
95 all_mems.sort_by_key(|x| std::cmp::Reverse(x.1.metadata.created_at));
97
98 let total = all_mems.len();
100 let layer_size = (total / max_layers).max(1);
101 let mut layers: Vec<Value> = Vec::new();
102
103 for (layer_idx, chunk) in all_mems.chunks(layer_size).enumerate().take(max_layers) {
104 let layer_mems: Vec<Value> = chunk
105 .iter()
106 .take(memories_per_layer)
107 .map(|(galaxy, mem)| {
108 json!({
109 "galaxy": galaxy_name(*galaxy),
110 "id": mem.metadata.id,
111 "content_preview": mem.content.chars().take(120).collect::<String>(),
112 "importance": mem.metadata.importance,
113 "created_at": mem.metadata.created_at.to_rfc3339(),
114 "tags": mem.metadata.tags,
115 })
116 })
117 .collect();
118
119 let avg_importance: f32 = if chunk.is_empty() {
120 0.0
121 } else {
122 chunk
123 .iter()
124 .map(|(_, m)| m.metadata.importance)
125 .sum::<f32>()
126 / chunk.len() as f32
127 };
128
129 layers.push(json!({
130 "layer": layer_idx,
131 "depth": if layer_idx == 0 { "newest" } else if layer_idx == max_layers - 1 { "oldest" } else { "middle" },
132 "count": chunk.len(),
133 "avg_importance": (avg_importance * 100.0).round() / 100.0,
134 "memories": layer_mems,
135 }));
136 }
137
138 Ok(json!({
139 "status": "success",
140 "total_memories": total,
141 "galaxies_searched": galaxies.len(),
142 "keyword": keyword,
143 "layers": layers,
144 }))
145 }
146 fn stats(&self) -> &ToolStats {
147 &self.stats
148 }
149}
150
151pub struct LearningPatternTool {
159 store: Arc<MemoryStore>,
160 stats: ToolStats,
161 effects: EffectRow,
162}
163
164impl LearningPatternTool {
165 pub fn new(store: Arc<MemoryStore>) -> Self {
166 Self {
167 store,
168 stats: ToolStats::default(),
169 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
170 }
171 }
172}
173
174#[async_trait]
175impl Tool for LearningPatternTool {
176 fn name(&self) -> &str {
177 "learning.pattern"
178 }
179 fn gana(&self) -> Gana {
180 Gana::Ox
181 }
182 fn effects(&self) -> &EffectRow {
183 &self.effects
184 }
185 fn description(&self) -> &str {
186 "Detect recurring patterns and themes across memory galaxies"
187 }
188 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
189 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
190 let min_frequency = args
191 .get("min_frequency")
192 .and_then(serde_json::Value::as_u64)
193 .unwrap_or(2) as usize;
194 let top_n = args
195 .get("top_n")
196 .and_then(serde_json::Value::as_u64)
197 .unwrap_or(10) as usize;
198 let scan_limit = args
199 .get("scan_limit")
200 .and_then(serde_json::Value::as_u64)
201 .unwrap_or(500) as usize;
202
203 let galaxies: Vec<Galaxy> = match galaxy_str {
204 Some(g) => vec![parse_galaxy(g)?],
205 None => Galaxy::memory_galaxies().to_vec(),
206 };
207
208 let mut tag_pairs: HashMap<(String, String), u32> = HashMap::new();
210 let mut keyword_freq: HashMap<String, u32> = HashMap::new();
211 let mut total_memories = 0usize;
212
213 for galaxy in &galaxies {
214 let mems = self.store.scan(*galaxy, scan_limit)?;
215 for mem in &mems {
216 total_memories += 1;
217 let tags = &mem.metadata.tags;
219 for i in 0..tags.len() {
220 for j in (i + 1)..tags.len() {
221 let pair = if tags[i] < tags[j] {
222 (tags[i].clone(), tags[j].clone())
223 } else {
224 (tags[j].clone(), tags[i].clone())
225 };
226 *tag_pairs.entry(pair).or_default() += 1;
227 }
228 }
229 for word in mem.content.split_whitespace() {
231 let w = word
232 .trim_matches(|c: char| !c.is_alphanumeric())
233 .to_lowercase();
234 if w.len() > 3 {
235 *keyword_freq.entry(w).or_default() += 1;
236 }
237 }
238 }
239 }
240
241 let mut tag_patterns: Vec<Value> = tag_pairs
243 .iter()
244 .filter(|&(_, count)| *count >= min_frequency as u32)
245 .map(|((t1, t2), count)| {
246 json!({
247 "tags": [t1, t2],
248 "co_occurrence": count,
249 })
250 })
251 .collect();
252 tag_patterns.sort_by(|a, b| {
253 b["co_occurrence"]
254 .as_u64()
255 .cmp(&a["co_occurrence"].as_u64())
256 });
257 tag_patterns.truncate(top_n);
258
259 let mut keywords: Vec<(String, u32)> = keyword_freq
261 .into_iter()
262 .filter(|(_, count)| *count >= min_frequency as u32)
263 .collect();
264 keywords.sort_by_key(|x| std::cmp::Reverse(x.1));
265 keywords.truncate(top_n);
266
267 let keyword_patterns: Vec<Value> = keywords
268 .into_iter()
269 .map(|(word, count)| {
270 json!({
271 "keyword": word,
272 "frequency": count,
273 })
274 })
275 .collect();
276
277 Ok(json!({
278 "status": "success",
279 "total_memories": total_memories,
280 "galaxies_scanned": galaxies.len(),
281 "tag_patterns": tag_patterns,
282 "keyword_patterns": keyword_patterns,
283 "min_frequency": min_frequency,
284 }))
285 }
286 fn stats(&self) -> &ToolStats {
287 &self.stats
288 }
289}
290
291pub struct LearningSuggestTool {
299 store: Arc<MemoryStore>,
300 stats: ToolStats,
301 effects: EffectRow,
302}
303
304impl LearningSuggestTool {
305 pub fn new(store: Arc<MemoryStore>) -> Self {
306 Self {
307 store,
308 stats: ToolStats::default(),
309 effects: EffectRow::read_only(vec![Resource::Galaxy("universal".into())]),
310 }
311 }
312}
313
314#[async_trait]
315impl Tool for LearningSuggestTool {
316 fn name(&self) -> &str {
317 "learning.suggest"
318 }
319 fn gana(&self) -> Gana {
320 Gana::Ox
321 }
322 fn effects(&self) -> &EffectRow {
323 &self.effects
324 }
325 fn description(&self) -> &str {
326 "Suggest learning paths based on memory gaps and importance clusters"
327 }
328 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
329 let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
330 let top_n = args
331 .get("top_n")
332 .and_then(serde_json::Value::as_u64)
333 .unwrap_or(10) as usize;
334
335 let galaxies: Vec<Galaxy> = match galaxy_str {
336 Some(g) => vec![parse_galaxy(g)?],
337 None => vec![Galaxy::Codex, Galaxy::Research, Galaxy::Tutorial],
338 };
339
340 let mut tag_stats: HashMap<String, (u32, f32, u64)> = HashMap::new(); for galaxy in &galaxies {
344 let mems = self.store.scan(*galaxy, 1000)?;
345 for mem in &mems {
346 for tag in &mem.metadata.tags {
347 let entry = tag_stats.entry(tag.clone()).or_insert((0, 0.0, 0));
348 entry.0 += 1;
349 entry.1 += mem.metadata.importance;
350 entry.2 += mem.metadata.access_count;
351 }
352 }
353 }
354
355 let mut tag_data: Vec<(String, u32, f32, u64)> = tag_stats
357 .into_iter()
358 .map(|(tag, (count, imp_sum, access))| (tag, count, imp_sum / count as f32, access))
359 .collect();
360
361 let mut gaps: Vec<Value> = tag_data
363 .iter()
364 .filter(|(_, count, imp, _)| *count <= 3 && *imp > 0.5)
365 .map(|(tag, count, imp, access)| {
366 json!({
367 "tag": tag,
368 "memory_count": count,
369 "avg_importance": (imp * 100.0).round() / 100.0,
370 "total_access": access,
371 "suggestion": format!("Topic '{}' has high importance but few memories — consider exploring further", tag),
372 })
373 })
374 .collect();
375 gaps.sort_by(|a, b| {
376 b["avg_importance"]
377 .as_f64()
378 .partial_cmp(&a["avg_importance"].as_f64())
379 .unwrap_or(std::cmp::Ordering::Equal)
380 });
381 gaps.truncate(top_n);
382
383 let mut saturated: Vec<Value> = tag_data
385 .iter()
386 .filter(|(_, count, imp, _)| *count >= 10 && *imp < 0.4)
387 .map(|(tag, count, imp, access)| {
388 json!({
389 "tag": tag,
390 "memory_count": count,
391 "avg_importance": (imp * 100.0).round() / 100.0,
392 "total_access": access,
393 "suggestion": format!("Topic '{}' is well-covered with {} memories — consider synthesizing or consolidating", tag, count),
394 })
395 })
396 .collect();
397 saturated.sort_by(|a, b| b["memory_count"].as_u64().cmp(&a["memory_count"].as_u64()));
398 saturated.truncate(top_n);
399
400 let mut hot: Vec<Value> = tag_data
402 .iter()
403 .filter(|(_, count, imp, _)| *count >= 5 && *imp > 0.6)
404 .map(|(tag, count, imp, access)| {
405 json!({
406 "tag": tag,
407 "memory_count": count,
408 "avg_importance": (imp * 100.0).round() / 100.0,
409 "total_access": access,
410 })
411 })
412 .collect();
413 hot.sort_by(|a, b| {
414 b["avg_importance"]
415 .as_f64()
416 .partial_cmp(&a["avg_importance"].as_f64())
417 .unwrap_or(std::cmp::Ordering::Equal)
418 });
419 hot.truncate(top_n);
420
421 tag_data.clear();
423
424 Ok(json!({
425 "status": "success",
426 "galaxies_scanned": galaxies.len(),
427 "gaps": gaps,
428 "saturated": saturated,
429 "hot_topics": hot,
430 "suggestion_count": gaps.len() + saturated.len(),
431 }))
432 }
433 fn stats(&self) -> &ToolStats {
434 &self.stats
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441
442 fn open_store() -> (tempfile::TempDir, Arc<MemoryStore>) {
443 let tmp = tempfile::tempdir().unwrap();
444 let store = MemoryStore::open_default(tmp.path()).unwrap();
445 (tmp, Arc::new(store))
446 }
447
448 fn seed_memories(store: &Arc<MemoryStore>) {
449 let galaxies = [Galaxy::Codex, Galaxy::Research, Galaxy::Tutorial];
450 let contents = [
451 "Rust programming language features and memory safety",
452 "Rust ownership model and borrow checker rules",
453 "Python async programming with asyncio library",
454 "Machine learning fundamentals and neural networks",
455 "Quantum computing principles and qubit operations",
456 "Database design patterns for scalable systems",
457 "Rust trait system and generic constraints",
458 "Python data science with pandas and numpy",
459 ];
460 let tags_sets: Vec<Vec<String>> = vec![
461 vec!["rust".into(), "programming".into()],
462 vec!["rust".into(), "programming".into(), "memory".into()],
463 vec!["python".into(), "programming".into(), "async".into()],
464 vec!["ml".into(), "neural".into()],
465 vec!["quantum".into(), "physics".into()],
466 vec!["database".into(), "design".into()],
467 vec!["rust".into(), "programming".into(), "traits".into()],
468 vec!["python".into(), "data".into()],
469 ];
470 let importances = [0.9, 0.8, 0.5, 0.7, 0.6, 0.4, 0.85, 0.3];
471
472 for i in 0..8 {
473 let galaxy = galaxies[i % 3];
474 let mut mem = wm_memory::Memory::new(galaxy, contents[i].into());
475 mem.metadata.tags = tags_sets[i].clone();
476 mem.metadata.importance = importances[i];
477 store.put(galaxy, &mem).unwrap();
478 }
479 }
480
481 #[tokio::test]
482 async fn archaeology_search_returns_layers() {
483 let (_tmp, store) = open_store();
484 seed_memories(&store);
485
486 let tool = ArchaeologySearchTool::new(store);
487 let result = tool
488 .call(&mut Context::default(), json!({"max_layers": 3}))
489 .await
490 .unwrap();
491 let obj = result.as_object().unwrap();
492 assert_eq!(obj["status"], "success");
493 assert!(obj["total_memories"].as_u64().unwrap() >= 8);
494 let layers = obj["layers"].as_array().unwrap();
495 assert!(!layers.is_empty());
496 assert!(layers.len() <= 3);
497 }
498
499 #[tokio::test]
500 async fn archaeology_search_with_keyword() {
501 let (_tmp, store) = open_store();
502 seed_memories(&store);
503
504 let tool = ArchaeologySearchTool::new(store);
505 let result = tool
506 .call(
507 &mut Context::default(),
508 json!({"keyword": "rust", "max_layers": 2}),
509 )
510 .await
511 .unwrap();
512 let obj = result.as_object().unwrap();
513 assert_eq!(obj["status"], "success");
514 assert!(obj["total_memories"].as_u64().unwrap() >= 3);
515 }
516
517 #[tokio::test]
518 async fn archaeology_search_empty_galaxy() {
519 let (_tmp, store) = open_store();
520 let tool = ArchaeologySearchTool::new(store);
521 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
522 let obj = result.as_object().unwrap();
523 assert_eq!(obj["status"], "success");
524 assert_eq!(obj["total_memories"], 0);
525 }
526
527 #[tokio::test]
528 async fn learning_pattern_detects_tag_co_occurrence() {
529 let (_tmp, store) = open_store();
530 seed_memories(&store);
531
532 let tool = LearningPatternTool::new(store);
533 let result = tool
534 .call(
535 &mut Context::default(),
536 json!({"min_frequency": 2, "top_n": 10}),
537 )
538 .await
539 .unwrap();
540 let obj = result.as_object().unwrap();
541 assert_eq!(obj["status"], "success");
542 assert!(obj["total_memories"].as_u64().unwrap() >= 8);
543 let tag_patterns = obj["tag_patterns"].as_array().unwrap();
545 assert!(!tag_patterns.is_empty());
546 }
547
548 #[tokio::test]
549 async fn learning_pattern_detects_keywords() {
550 let (_tmp, store) = open_store();
551 seed_memories(&store);
552
553 let tool = LearningPatternTool::new(store);
554 let result = tool
555 .call(
556 &mut Context::default(),
557 json!({"min_frequency": 2, "top_n": 5}),
558 )
559 .await
560 .unwrap();
561 let obj = result.as_object().unwrap();
562 let keywords = obj["keyword_patterns"].as_array().unwrap();
563 assert!(!keywords.is_empty());
564 let has_rust = keywords
566 .iter()
567 .any(|k| k["keyword"].as_str().unwrap_or("") == "rust");
568 assert!(has_rust, "Expected 'rust' in keyword patterns");
569 }
570
571 #[tokio::test]
572 async fn learning_suggest_finds_gaps_and_hot_topics() {
573 let (_tmp, store) = open_store();
574 seed_memories(&store);
575
576 let tool = LearningSuggestTool::new(store);
577 let result = tool
578 .call(&mut Context::default(), json!({"top_n": 5}))
579 .await
580 .unwrap();
581 let obj = result.as_object().unwrap();
582 assert_eq!(obj["status"], "success");
583 let gaps = obj["gaps"].as_array().unwrap();
585 let hot = obj["hot_topics"].as_array().unwrap();
586 assert!(!gaps.is_empty() || !hot.is_empty());
588 }
589
590 #[tokio::test]
591 async fn learning_suggest_empty_store() {
592 let (_tmp, store) = open_store();
593 let tool = LearningSuggestTool::new(store);
594 let result = tool.call(&mut Context::default(), json!({})).await.unwrap();
595 let obj = result.as_object().unwrap();
596 assert_eq!(obj["status"], "success");
597 assert_eq!(obj["gaps"].as_array().unwrap().len(), 0);
598 assert_eq!(obj["hot_topics"].as_array().unwrap().len(), 0);
599 }
600}