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, Gana, Resource, Tool, ToolStats};
16use wm_memory::{Association, AssociationStore, LinkType, MemoryStore};
17
18use super::common::{galaxy_name, parse_galaxy};
19
20fn extract_entities(content: &str) -> Vec<String> {
26 let mut entities = Vec::new();
27 let words: Vec<&str> = content.split_whitespace().collect();
28
29 let mut i = 0;
30 while i < words.len() {
31 let word = words[i];
32
33 if word.chars().next().is_some_and(char::is_uppercase) && word.len() > 1 {
35 let mut phrase = vec![word];
37 let mut j = i + 1;
38 while j < words.len() {
39 let next = words[j];
40 if next.chars().next().is_some_and(char::is_uppercase) && next.len() > 1 {
41 phrase.push(next);
42 j += 1;
43 } else {
44 break;
45 }
46 }
47 while phrase.len() > 1 && is_common_word(&phrase[0].to_lowercase()) {
49 phrase.remove(0);
50 }
51 let entity = phrase.join(" ");
52 let lower = entity.to_lowercase();
54 if !entity.is_empty() && !is_common_word(&lower) {
55 entities.push(entity);
56 }
57 i = j;
58 } else {
59 i += 1;
60 }
61 }
62
63 entities
64}
65
66fn is_common_word(s: &str) -> bool {
68 matches!(
69 s,
70 "the"
71 | "a"
72 | "an"
73 | "this"
74 | "that"
75 | "these"
76 | "those"
77 | "it"
78 | "is"
79 | "was"
80 | "are"
81 | "were"
82 | "be"
83 | "been"
84 | "we"
85 | "they"
86 | "he"
87 | "she"
88 | "i"
89 | "you"
90 | "in"
91 | "on"
92 | "at"
93 | "to"
94 | "for"
95 | "of"
96 | "with"
97 | "and"
98 | "or"
99 | "but"
100 | "not"
101 | "if"
102 | "then"
103 | "when"
104 | "where"
105 | "what"
106 | "who"
107 | "how"
108 | "why"
109 | "there"
110 | "here"
111 | "so"
112 | "no"
113 | "yes"
114 )
115}
116
117fn detect_link_type(content: &str, entity_a: &str, entity_b: &str) -> LinkType {
119 let lower = content.to_lowercase();
120 let a_lower = entity_a.to_lowercase();
121 let b_lower = entity_b.to_lowercase();
122
123 if let Some(pos_a) = lower.find(&a_lower) {
125 let after_a = pos_a + a_lower.len();
126 if let Some(pos_b) = lower[after_a..].find(&b_lower) {
127 let between = &lower[after_a..after_a + pos_b];
128 let between_trimmed = between.trim();
129
130 if between_trimmed.contains("because")
131 || between_trimmed.contains("causes")
132 || between_trimmed.contains("leads to")
133 || between_trimmed.contains("results in")
134 {
135 return LinkType::Causal;
136 }
137 if between_trimmed.contains("before")
138 || between_trimmed.contains("after")
139 || between_trimmed.contains("then")
140 || between_trimmed.contains("followed by")
141 {
142 return LinkType::Temporal;
143 }
144 if between_trimmed.contains("extends")
145 || between_trimmed.contains("refines")
146 || between_trimmed.contains("builds on")
147 || between_trimmed.contains("improves")
148 {
149 return LinkType::Extends;
150 }
151 if between_trimmed.contains("contradicts")
152 || between_trimmed.contains("but")
153 || between_trimmed.contains("however")
154 || between_trimmed.contains("opposes")
155 {
156 return LinkType::Contradicts;
157 }
158 if between_trimmed.contains("replaces")
159 || between_trimmed.contains("supersedes")
160 || between_trimmed.contains("instead of")
161 {
162 return LinkType::Supersedes;
163 }
164 if between_trimmed.contains("triggers")
165 || between_trimmed.contains("cascades")
166 || between_trimmed.contains("chain")
167 {
168 return LinkType::Cascade;
169 }
170 }
171 }
172 LinkType::Related
173}
174
175pub struct KgExtractTool {
181 store: Arc<MemoryStore>,
182 stats: ToolStats,
183 effects: EffectRow,
184}
185
186impl KgExtractTool {
187 pub fn new(store: Arc<MemoryStore>) -> Self {
188 Self {
189 store,
190 stats: ToolStats::default(),
191 effects: EffectRow {
192 writes: vec![Resource::Galaxy("associations".into())],
193 reads: vec![Resource::Galaxy("codex".into())],
194 ..Default::default()
195 },
196 }
197 }
198}
199
200#[async_trait]
201impl Tool for KgExtractTool {
202 fn name(&self) -> &str {
203 "kg.extract"
204 }
205 fn gana(&self) -> Gana {
206 Gana::Net
207 }
208 fn effects(&self) -> &EffectRow {
209 &self.effects
210 }
211 fn description(&self) -> &str {
212 "Extract entities from memory content and create typed associations (knowledge graph)"
213 }
214 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
215 let galaxy_str = args
216 .get("galaxy")
217 .and_then(|v| v.as_str())
218 .unwrap_or("codex");
219 let galaxy = parse_galaxy(galaxy_str)?;
220 let limit = args
221 .get("limit")
222 .and_then(serde_json::Value::as_u64)
223 .unwrap_or(200) as usize;
224
225 let memories = self.store.scan(galaxy, limit)?;
226 let env = self.store.env();
227 let assoc_store = AssociationStore::open(env)?;
228
229 let mut entity_index: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
231
232 for mem in &memories {
233 let entities = extract_entities(&mem.content);
234 for entity in entities {
235 entity_index
236 .entry(entity.to_lowercase())
237 .or_default()
238 .push(mem.metadata.id);
239 }
240 }
241
242 let mut created = 0u32;
244 let mut skipped = 0u32;
245 for mem_ids in entity_index.values() {
246 if mem_ids.len() < 2 {
247 continue;
248 }
249 for i in 0..mem_ids.len() {
250 for j in (i + 1)..mem_ids.len() {
251 let src = mem_ids[i];
252 let tgt = mem_ids[j];
253
254 if assoc_store.get(env, src, tgt).unwrap_or(None).is_some() {
256 skipped += 1;
257 continue;
258 }
259
260 let src_mem = memories.iter().find(|m| m.metadata.id == src);
262 let tgt_mem = memories.iter().find(|m| m.metadata.id == tgt);
263 let link_type = if let (Some(s), Some(t)) = (src_mem, tgt_mem) {
264 detect_link_type(&s.content, &s.content, &t.content)
266 } else {
267 LinkType::Related
268 };
269
270 let weight = 0.5f32.mul_add((mem_ids.len() - 2).min(5) as f32, 0.5);
271 let assoc = Association::new(src, tgt, link_type, weight.min(1.0));
272 let _ = assoc_store.put(env, &assoc);
273 created += 1;
274 }
275 }
276 }
277
278 Ok(json!({
279 "status": "success",
280 "galaxy": galaxy_name(galaxy),
281 "scanned": memories.len(),
282 "entities_found": entity_index.len(),
283 "associations_created": created,
284 "associations_skipped_existing": skipped,
285 }))
286 }
287 fn stats(&self) -> &ToolStats {
288 &self.stats
289 }
290}
291
292pub struct KgQueryTool {
297 store: Arc<MemoryStore>,
298 stats: ToolStats,
299 effects: EffectRow,
300}
301
302impl KgQueryTool {
303 pub fn new(store: Arc<MemoryStore>) -> Self {
304 Self {
305 store,
306 stats: ToolStats::default(),
307 effects: EffectRow::read_only(vec![
308 Resource::Galaxy("codex".into()),
309 Resource::Galaxy("associations".into()),
310 ]),
311 }
312 }
313}
314
315#[async_trait]
316impl Tool for KgQueryTool {
317 fn name(&self) -> &str {
318 "kg.query"
319 }
320 fn gana(&self) -> Gana {
321 Gana::Net
322 }
323 fn effects(&self) -> &EffectRow {
324 &self.effects
325 }
326 fn description(&self) -> &str {
327 "Query the knowledge graph for an entity (find memories and associations)"
328 }
329 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
330 let entity = args
331 .get("entity")
332 .and_then(|v| v.as_str())
333 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'entity' parameter".into()))?;
334 let galaxy_str = args
335 .get("galaxy")
336 .and_then(|v| v.as_str())
337 .unwrap_or("codex");
338 let galaxy = parse_galaxy(galaxy_str)?;
339 let limit = args
340 .get("limit")
341 .and_then(serde_json::Value::as_u64)
342 .unwrap_or(50) as usize;
343
344 let memories = self.store.scan(galaxy, limit)?;
345 let entity_lower = entity.to_lowercase();
346
347 let matching: Vec<_> = memories
349 .iter()
350 .filter(|m| m.content.to_lowercase().contains(&entity_lower))
351 .collect();
352
353 let env = self.store.env();
354 let assoc_store = AssociationStore::open(env)?;
355
356 let mut edges = Vec::new();
358 let mut connected_ids: std::collections::HashSet<uuid::Uuid> =
359 std::collections::HashSet::new();
360
361 for mem in &matching {
362 let from = assoc_store
363 .find_from(env, mem.metadata.id)
364 .unwrap_or_default();
365 let to = assoc_store
366 .find_to(env, mem.metadata.id)
367 .unwrap_or_default();
368
369 for a in &from {
370 edges.push(json!({
371 "source": a.source,
372 "target": a.target,
373 "link_type": a.link_type.as_str(),
374 "weight": a.weight,
375 }));
376 connected_ids.insert(a.target);
377 }
378 for a in &to {
379 edges.push(json!({
380 "source": a.source,
381 "target": a.target,
382 "link_type": a.link_type.as_str(),
383 "weight": a.weight,
384 }));
385 connected_ids.insert(a.source);
386 }
387 }
388
389 let node_mems: Vec<Value> = matching
390 .iter()
391 .map(|m| {
392 json!({
393 "id": m.metadata.id,
394 "content_preview": m.content.chars().take(200).collect::<String>(),
395 "tags": m.metadata.tags,
396 "galaxy": galaxy_name(galaxy),
397 })
398 })
399 .collect();
400
401 Ok(json!({
402 "status": "success",
403 "entity": entity,
404 "galaxy": galaxy_name(galaxy),
405 "matching_memories": matching.len(),
406 "nodes": node_mems,
407 "edges": edges,
408 "edge_count": edges.len(),
409 "connected_entities": connected_ids.len(),
410 }))
411 }
412 fn stats(&self) -> &ToolStats {
413 &self.stats
414 }
415}
416
417pub struct KgTopTool {
423 store: Arc<MemoryStore>,
424 stats: ToolStats,
425 effects: EffectRow,
426}
427
428impl KgTopTool {
429 pub fn new(store: Arc<MemoryStore>) -> Self {
430 Self {
431 store,
432 stats: ToolStats::default(),
433 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
434 }
435 }
436}
437
438#[async_trait]
439impl Tool for KgTopTool {
440 fn name(&self) -> &str {
441 "kg.top"
442 }
443 fn gana(&self) -> Gana {
444 Gana::HairyHead
445 }
446 fn effects(&self) -> &EffectRow {
447 &self.effects
448 }
449 fn description(&self) -> &str {
450 "Find top entities by connection count (hub/god nodes in the knowledge graph)"
451 }
452 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
453 let galaxy_str = args
454 .get("galaxy")
455 .and_then(|v| v.as_str())
456 .unwrap_or("codex");
457 let galaxy = parse_galaxy(galaxy_str)?;
458 let limit = args
459 .get("limit")
460 .and_then(serde_json::Value::as_u64)
461 .unwrap_or(500) as usize;
462 let top_n = args
463 .get("top_n")
464 .and_then(serde_json::Value::as_u64)
465 .unwrap_or(10) as usize;
466
467 let memories = self.store.scan(galaxy, limit)?;
468
469 let mut entity_freq: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
471 for mem in &memories {
472 let entities = extract_entities(&mem.content);
473 for entity in entities {
474 let entry = entity_freq.entry(entity.to_lowercase()).or_default();
475 if !entry.contains(&mem.metadata.id) {
476 entry.push(mem.metadata.id);
477 }
478 }
479 }
480
481 let mut ranked: Vec<(String, Vec<uuid::Uuid>)> = entity_freq.into_iter().collect();
483 ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1.len()));
484
485 let top: Vec<Value> = ranked
486 .iter()
487 .take(top_n)
488 .map(|(entity, ids)| {
489 json!({
490 "entity": entity,
491 "memory_count": ids.len(),
492 "sample_memory_ids": ids.iter().take(5).map(std::string::ToString::to_string).collect::<Vec<_>>(),
493 })
494 })
495 .collect();
496
497 Ok(json!({
498 "status": "success",
499 "galaxy": galaxy_name(galaxy),
500 "scanned": memories.len(),
501 "total_entities": ranked.len(),
502 "top_entities": top,
503 }))
504 }
505 fn stats(&self) -> &ToolStats {
506 &self.stats
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513 use tempfile::tempdir;
514
515 fn open_store() -> (tempfile::TempDir, MemoryStore) {
516 let tmp = tempdir().unwrap();
517 let store = MemoryStore::open_default(tmp.path()).unwrap();
518 (tmp, store)
519 }
520
521 #[tokio::test]
522 async fn extract_entities_finds_capitalized_words() {
523 let entities = extract_entities("Rust is a language. Python is also great.");
524 assert!(entities.contains(&"Rust".to_string()));
525 assert!(entities.contains(&"Python".to_string()));
526 }
527
528 #[tokio::test]
529 async fn extract_entities_finds_multi_word_phrases() {
530 let entities = extract_entities("The White Magic project uses LMDB storage.");
531 assert!(entities.contains(&"White Magic".to_string()));
532 assert!(entities.contains(&"LMDB".to_string()));
533 }
534
535 #[tokio::test]
536 async fn extract_entities_ignores_common_words() {
537 let entities = extract_entities("The quick brown fox jumps over the lazy dog.");
538 assert!(!entities.iter().any(|e| e == "The"));
540 }
541
542 #[tokio::test]
543 async fn detect_link_type_causal() {
544 let lt = detect_link_type(
545 "Rust causes fast performance because of zero-cost abstractions",
546 "Rust",
547 "zero-cost abstractions",
548 );
549 assert_eq!(lt, LinkType::Causal);
550 }
551
552 #[tokio::test]
553 async fn detect_link_type_temporal() {
554 let lt = detect_link_type(
555 "First we tried Python, then we switched to Rust",
556 "Python",
557 "Rust",
558 );
559 assert_eq!(lt, LinkType::Temporal);
560 }
561
562 #[tokio::test]
563 async fn detect_link_type_related_default() {
564 let lt = detect_link_type("Rust and Python are languages", "Rust", "Python");
565 assert_eq!(lt, LinkType::Related);
566 }
567
568 #[tokio::test]
569 async fn kg_extract_creates_associations() {
570 let (_tmp, store) = open_store();
571 let store = Arc::new(store);
572
573 let mem1 = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Rust is a fast language".into());
574 let mem2 = wm_memory::Memory::new(
575 wm_core::Galaxy::Codex,
576 "Rust is also a great language".into(),
577 );
578 store.put(wm_core::Galaxy::Codex, &mem1).unwrap();
579 store.put(wm_core::Galaxy::Codex, &mem2).unwrap();
580
581 let tool = KgExtractTool::new(store.clone());
582 let result = tool
583 .call(&mut Context::default(), json!({"galaxy": "codex"}))
584 .await
585 .unwrap();
586 let obj = result.as_object().unwrap();
587 assert_eq!(obj["status"], "success");
588 assert_eq!(obj["scanned"], 2);
589 assert!(obj["entities_found"].as_u64().unwrap() >= 1);
591
592 let env = store.env();
594 let assoc_store = AssociationStore::open(env).unwrap();
595 let count = assoc_store.count(env).unwrap();
596 assert!(count > 0, "should have created at least one association");
597 }
598
599 #[tokio::test]
600 async fn kg_query_finds_entity() {
601 let (_tmp, store) = open_store();
602
603 let mem = wm_memory::Memory::new(
604 wm_core::Galaxy::Codex,
605 "Rust is a systems programming language".into(),
606 );
607 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
608
609 let tool = KgQueryTool::new(Arc::new(store));
610 let result = tool
611 .call(
612 &mut Context::default(),
613 json!({"entity": "Rust", "galaxy": "codex"}),
614 )
615 .await
616 .unwrap();
617 let obj = result.as_object().unwrap();
618 assert_eq!(obj["status"], "success");
619 assert_eq!(obj["matching_memories"], 1);
620 assert_eq!(obj["entity"], "Rust");
621 }
622
623 #[tokio::test]
624 async fn kg_query_missing_entity_param_errors() {
625 let (_tmp, store) = open_store();
626 let tool = KgQueryTool::new(Arc::new(store));
627 let result = tool.call(&mut Context::default(), json!({})).await;
628 assert!(result.is_err());
629 }
630
631 #[tokio::test]
632 async fn kg_top_ranks_entities() {
633 let (_tmp, store) = open_store();
634
635 for i in 0..5 {
637 let content = format!("Rust is mentioned in memory number {i}");
638 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, content);
639 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
640 }
641 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Python is great".into());
643 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
644
645 let tool = KgTopTool::new(Arc::new(store));
646 let result = tool
647 .call(
648 &mut Context::default(),
649 json!({"galaxy": "codex", "top_n": 5}),
650 )
651 .await
652 .unwrap();
653 let obj = result.as_object().unwrap();
654 assert_eq!(obj["status"], "success");
655 assert_eq!(obj["scanned"], 6);
656 let top = obj["top_entities"].as_array().unwrap();
657 assert!(!top.is_empty());
658 let top_entity = top[0]["entity"].as_str().unwrap();
660 assert_eq!(top_entity, "rust");
661 assert_eq!(top[0]["memory_count"], 5);
662 }
663
664 #[tokio::test]
665 async fn kg_tool_names_are_correct() {
666 let store = Arc::new(open_store().1);
667 assert_eq!(KgExtractTool::new(store.clone()).name(), "kg.extract");
668 assert_eq!(KgQueryTool::new(store.clone()).name(), "kg.query");
669 assert_eq!(KgTopTool::new(store).name(), "kg.top");
670 }
671
672 #[tokio::test]
673 async fn kg_tool_ganas_are_correct() {
674 let store = Arc::new(open_store().1);
675 assert_eq!(KgExtractTool::new(store.clone()).gana(), Gana::Net);
676 assert_eq!(KgQueryTool::new(store.clone()).gana(), Gana::Net);
677 assert_eq!(KgTopTool::new(store).gana(), Gana::HairyHead);
678 }
679}