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, schema};
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 input_schema(&self) -> Value {
330 schema(
331 &json!({
332 "entity": super::common::str_prop("Entity name to query (required)"),
333 "galaxy": super::common::str_prop("Galaxy filter (default: codex)"),
334 "limit": super::common::int_prop("Maximum results (default 50)"),
335 }),
336 &["entity"],
337 )
338 }
339 fn description(&self) -> &str {
340 "Query the knowledge graph for an entity (find memories and associations)"
341 }
342 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
343 let entity = args
344 .get("entity")
345 .and_then(|v| v.as_str())
346 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'entity' parameter".into()))?;
347 let galaxy_str = args
348 .get("galaxy")
349 .and_then(|v| v.as_str())
350 .unwrap_or("codex");
351 let galaxy = parse_galaxy(galaxy_str)?;
352 let limit = args
353 .get("limit")
354 .and_then(serde_json::Value::as_u64)
355 .unwrap_or(50) as usize;
356
357 let memories = self.store.scan(galaxy, limit)?;
358 let entity_lower = entity.to_lowercase();
359
360 let matching: Vec<_> = memories
362 .iter()
363 .filter(|m| m.content.to_lowercase().contains(&entity_lower))
364 .collect();
365
366 let env = self.store.env();
367 let assoc_store = AssociationStore::open(env)?;
368
369 let mut edges = Vec::new();
371 let mut connected_ids: std::collections::HashSet<uuid::Uuid> =
372 std::collections::HashSet::new();
373
374 for mem in &matching {
375 let from = assoc_store
376 .find_from(env, mem.metadata.id)
377 .unwrap_or_default();
378 let to = assoc_store
379 .find_to(env, mem.metadata.id)
380 .unwrap_or_default();
381
382 for a in &from {
383 edges.push(json!({
384 "source": a.source,
385 "target": a.target,
386 "link_type": a.link_type.as_str(),
387 "weight": a.weight,
388 }));
389 connected_ids.insert(a.target);
390 }
391 for a in &to {
392 edges.push(json!({
393 "source": a.source,
394 "target": a.target,
395 "link_type": a.link_type.as_str(),
396 "weight": a.weight,
397 }));
398 connected_ids.insert(a.source);
399 }
400 }
401
402 let node_mems: Vec<Value> = matching
403 .iter()
404 .map(|m| {
405 json!({
406 "id": m.metadata.id,
407 "content_preview": m.content.chars().take(200).collect::<String>(),
408 "tags": m.metadata.tags,
409 "galaxy": galaxy_name(galaxy),
410 })
411 })
412 .collect();
413
414 Ok(json!({
415 "status": "success",
416 "entity": entity,
417 "galaxy": galaxy_name(galaxy),
418 "matching_memories": matching.len(),
419 "nodes": node_mems,
420 "edges": edges,
421 "edge_count": edges.len(),
422 "connected_entities": connected_ids.len(),
423 }))
424 }
425 fn stats(&self) -> &ToolStats {
426 &self.stats
427 }
428}
429
430pub struct KgTopTool {
436 store: Arc<MemoryStore>,
437 stats: ToolStats,
438 effects: EffectRow,
439}
440
441impl KgTopTool {
442 pub fn new(store: Arc<MemoryStore>) -> Self {
443 Self {
444 store,
445 stats: ToolStats::default(),
446 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
447 }
448 }
449}
450
451#[async_trait]
452impl Tool for KgTopTool {
453 fn name(&self) -> &str {
454 "kg.top"
455 }
456 fn gana(&self) -> Gana {
457 Gana::HairyHead
458 }
459 fn effects(&self) -> &EffectRow {
460 &self.effects
461 }
462 fn description(&self) -> &str {
463 "Find top entities by connection count (hub/god nodes in the knowledge graph)"
464 }
465 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
466 let galaxy_str = args
467 .get("galaxy")
468 .and_then(|v| v.as_str())
469 .unwrap_or("codex");
470 let galaxy = parse_galaxy(galaxy_str)?;
471 let limit = args
472 .get("limit")
473 .and_then(serde_json::Value::as_u64)
474 .unwrap_or(500) as usize;
475 let top_n = args
476 .get("top_n")
477 .and_then(serde_json::Value::as_u64)
478 .unwrap_or(10) as usize;
479
480 let memories = self.store.scan(galaxy, limit)?;
481
482 let mut entity_freq: HashMap<String, Vec<uuid::Uuid>> = HashMap::new();
484 for mem in &memories {
485 let entities = extract_entities(&mem.content);
486 for entity in entities {
487 let entry = entity_freq.entry(entity.to_lowercase()).or_default();
488 if !entry.contains(&mem.metadata.id) {
489 entry.push(mem.metadata.id);
490 }
491 }
492 }
493
494 let mut ranked: Vec<(String, Vec<uuid::Uuid>)> = entity_freq.into_iter().collect();
496 ranked.sort_by_key(|entry| std::cmp::Reverse(entry.1.len()));
497
498 let top: Vec<Value> = ranked
499 .iter()
500 .take(top_n)
501 .map(|(entity, ids)| {
502 json!({
503 "entity": entity,
504 "memory_count": ids.len(),
505 "sample_memory_ids": ids.iter().take(5).map(std::string::ToString::to_string).collect::<Vec<_>>(),
506 })
507 })
508 .collect();
509
510 Ok(json!({
511 "status": "success",
512 "galaxy": galaxy_name(galaxy),
513 "scanned": memories.len(),
514 "total_entities": ranked.len(),
515 "top_entities": top,
516 }))
517 }
518 fn stats(&self) -> &ToolStats {
519 &self.stats
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526 use tempfile::tempdir;
527
528 fn open_store() -> (tempfile::TempDir, MemoryStore) {
529 let tmp = tempdir().unwrap();
530 let store = MemoryStore::open_default(tmp.path()).unwrap();
531 (tmp, store)
532 }
533
534 #[tokio::test]
535 async fn extract_entities_finds_capitalized_words() {
536 let entities = extract_entities("Rust is a language. Python is also great.");
537 assert!(entities.contains(&"Rust".to_string()));
538 assert!(entities.contains(&"Python".to_string()));
539 }
540
541 #[tokio::test]
542 async fn extract_entities_finds_multi_word_phrases() {
543 let entities = extract_entities("The White Magic project uses LMDB storage.");
544 assert!(entities.contains(&"White Magic".to_string()));
545 assert!(entities.contains(&"LMDB".to_string()));
546 }
547
548 #[tokio::test]
549 async fn extract_entities_ignores_common_words() {
550 let entities = extract_entities("The quick brown fox jumps over the lazy dog.");
551 assert!(!entities.iter().any(|e| e == "The"));
553 }
554
555 #[tokio::test]
556 async fn detect_link_type_causal() {
557 let lt = detect_link_type(
558 "Rust causes fast performance because of zero-cost abstractions",
559 "Rust",
560 "zero-cost abstractions",
561 );
562 assert_eq!(lt, LinkType::Causal);
563 }
564
565 #[tokio::test]
566 async fn detect_link_type_temporal() {
567 let lt = detect_link_type(
568 "First we tried Python, then we switched to Rust",
569 "Python",
570 "Rust",
571 );
572 assert_eq!(lt, LinkType::Temporal);
573 }
574
575 #[tokio::test]
576 async fn detect_link_type_related_default() {
577 let lt = detect_link_type("Rust and Python are languages", "Rust", "Python");
578 assert_eq!(lt, LinkType::Related);
579 }
580
581 #[tokio::test]
582 async fn kg_extract_creates_associations() {
583 let (_tmp, store) = open_store();
584 let store = Arc::new(store);
585
586 let mem1 = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Rust is a fast language".into());
587 let mem2 = wm_memory::Memory::new(
588 wm_core::Galaxy::Codex,
589 "Rust is also a great language".into(),
590 );
591 store.put(wm_core::Galaxy::Codex, &mem1).unwrap();
592 store.put(wm_core::Galaxy::Codex, &mem2).unwrap();
593
594 let tool = KgExtractTool::new(store.clone());
595 let result = tool
596 .call(&mut Context::default(), json!({"galaxy": "codex"}))
597 .await
598 .unwrap();
599 let obj = result.as_object().unwrap();
600 assert_eq!(obj["status"], "success");
601 assert_eq!(obj["scanned"], 2);
602 assert!(obj["entities_found"].as_u64().unwrap() >= 1);
604
605 let env = store.env();
607 let assoc_store = AssociationStore::open(env).unwrap();
608 let count = assoc_store.count(env).unwrap();
609 assert!(count > 0, "should have created at least one association");
610 }
611
612 #[tokio::test]
613 async fn kg_query_finds_entity() {
614 let (_tmp, store) = open_store();
615
616 let mem = wm_memory::Memory::new(
617 wm_core::Galaxy::Codex,
618 "Rust is a systems programming language".into(),
619 );
620 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
621
622 let tool = KgQueryTool::new(Arc::new(store));
623 let result = tool
624 .call(
625 &mut Context::default(),
626 json!({"entity": "Rust", "galaxy": "codex"}),
627 )
628 .await
629 .unwrap();
630 let obj = result.as_object().unwrap();
631 assert_eq!(obj["status"], "success");
632 assert_eq!(obj["matching_memories"], 1);
633 assert_eq!(obj["entity"], "Rust");
634 }
635
636 #[tokio::test]
637 async fn kg_query_missing_entity_param_errors() {
638 let (_tmp, store) = open_store();
639 let tool = KgQueryTool::new(Arc::new(store));
640 let result = tool.call(&mut Context::default(), json!({})).await;
641 assert!(result.is_err());
642 }
643
644 #[tokio::test]
645 async fn kg_top_ranks_entities() {
646 let (_tmp, store) = open_store();
647
648 for i in 0..5 {
650 let content = format!("Rust is mentioned in memory number {i}");
651 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, content);
652 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
653 }
654 let mem = wm_memory::Memory::new(wm_core::Galaxy::Codex, "Python is great".into());
656 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
657
658 let tool = KgTopTool::new(Arc::new(store));
659 let result = tool
660 .call(
661 &mut Context::default(),
662 json!({"galaxy": "codex", "top_n": 5}),
663 )
664 .await
665 .unwrap();
666 let obj = result.as_object().unwrap();
667 assert_eq!(obj["status"], "success");
668 assert_eq!(obj["scanned"], 6);
669 let top = obj["top_entities"].as_array().unwrap();
670 assert!(!top.is_empty());
671 let top_entity = top[0]["entity"].as_str().unwrap();
673 assert_eq!(top_entity, "rust");
674 assert_eq!(top[0]["memory_count"], 5);
675 }
676
677 #[tokio::test]
678 async fn kg_tool_names_are_correct() {
679 let store = Arc::new(open_store().1);
680 assert_eq!(KgExtractTool::new(store.clone()).name(), "kg.extract");
681 assert_eq!(KgQueryTool::new(store.clone()).name(), "kg.query");
682 assert_eq!(KgTopTool::new(store).name(), "kg.top");
683 }
684
685 #[tokio::test]
686 async fn kg_tool_ganas_are_correct() {
687 let store = Arc::new(open_store().1);
688 assert_eq!(KgExtractTool::new(store.clone()).gana(), Gana::Net);
689 assert_eq!(KgQueryTool::new(store.clone()).gana(), Gana::Net);
690 assert_eq!(KgTopTool::new(store).gana(), Gana::HairyHead);
691 }
692}