1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::sync::Arc;
9use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
10use wm_memory::{Association, AssociationStore, LinkType, MemoryStore};
11
12use super::common::parse_galaxy;
13
14pub struct MemoryAssociateMineTool {
15 store: Arc<MemoryStore>,
16 stats: ToolStats,
17 effects: EffectRow,
18}
19
20impl MemoryAssociateMineTool {
21 pub fn new(store: Arc<MemoryStore>) -> Self {
22 Self {
23 store,
24 stats: ToolStats::default(),
25 effects: EffectRow {
26 writes: vec![Resource::Galaxy("associations".into())],
27 ..Default::default()
28 },
29 }
30 }
31}
32
33#[async_trait]
34impl Tool for MemoryAssociateMineTool {
35 fn input_schema(&self) -> Value {
36 super::common::schema(
37 &json!({
38 "galaxy": super::common::str_prop("Galaxy to mine (optional; default codex)"),
39 "limit": super::common::int_prop("Maximum memories to mine (default 50)"),
40 }),
41 &[],
42 )
43 }
44 fn name(&self) -> &str {
45 "memory.associate_mine"
46 }
47 fn gana(&self) -> Gana {
48 Gana::Net
49 }
50 fn effects(&self) -> &EffectRow {
51 &self.effects
52 }
53 fn description(&self) -> &str {
54 "Mine associations across galaxies using keyword overlap"
55 }
56 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
57 let galaxy_name_str = args
58 .get("galaxy")
59 .and_then(|v| v.as_str())
60 .unwrap_or("codex");
61 let galaxy = parse_galaxy(galaxy_name_str)?;
62 let limit = args
63 .get("limit")
64 .and_then(serde_json::Value::as_u64)
65 .unwrap_or(50) as usize;
66 let memories = self.store.scan(galaxy, limit)?;
67 let env = self.store.env();
68 let assoc_store = AssociationStore::open(env)?;
69 let mut proposed = 0u32;
70 for i in 0..memories.len() {
71 for j in (i + 1)..memories.len() {
72 let a = &memories[i];
73 let b = &memories[j];
74 let a_words: std::collections::HashSet<&str> =
75 a.content.split_whitespace().collect();
76 let b_words: std::collections::HashSet<&str> =
77 b.content.split_whitespace().collect();
78 let intersection = a_words.intersection(&b_words).count();
79 let union = a_words.union(&b_words).count();
80 if union > 0 && intersection > 2 {
81 let strength = intersection as f32 / union as f32;
82 if strength > 0.3 {
83 let assoc = Association::new(
84 a.metadata.id,
85 b.metadata.id,
86 LinkType::Related,
87 strength,
88 );
89 let _ = assoc_store.put(env, &assoc);
90 proposed += 1;
91 }
92 }
93 }
94 }
95 Ok(json!({
96 "status": "success",
97 "galaxy": galaxy_name_str,
98 "scanned": memories.len(),
99 "proposed_associations": proposed,
100 }))
101 }
102 fn stats(&self) -> &ToolStats {
103 &self.stats
104 }
105}
106
107pub struct MemoryCorroborateTool {
128 store: Arc<MemoryStore>,
129 stats: ToolStats,
130 effects: EffectRow,
131}
132
133impl MemoryCorroborateTool {
134 pub fn new(store: Arc<MemoryStore>) -> Self {
135 Self {
136 store,
137 stats: ToolStats::default(),
138 effects: EffectRow {
139 writes: super::common::memory_galaxy_writes(),
140 reads: super::common::memory_galaxy_reads(),
141 ..Default::default()
142 },
143 }
144 }
145}
146
147#[async_trait]
148impl Tool for MemoryCorroborateTool {
149 fn name(&self) -> &str {
150 "memory.corroborate"
151 }
152 fn gana(&self) -> Gana {
153 Gana::Net
154 }
155 fn effects(&self) -> &EffectRow {
156 &self.effects
157 }
158 fn description(&self) -> &str {
159 "Record this session's corroboration of a memory (bridging consensus counter input; ranking effect only with WM_CORROBORATION_WEIGHT > 0)"
160 }
161 fn input_schema(&self) -> Value {
162 super::common::schema(
163 &json!({
164 "id": super::common::str_prop("Memory UUID to corroborate"),
165 "session": super::common::str_prop("Corroborating session UUID (defaults to the dispatch session; required when the dispatch carries none)"),
166 }),
167 &["id"],
168 )
169 }
170 async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
171 let id_str = args.get("id").and_then(Value::as_str).ok_or_else(|| {
172 wm_core::CoreError::InvalidArgs("id (memory UUID string) required".into())
173 })?;
174 let id = uuid::Uuid::parse_str(id_str)
175 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid memory UUID: {e}")))?;
176 let session = args
177 .get("session")
178 .and_then(Value::as_str)
179 .map(uuid::Uuid::parse_str)
180 .transpose()
181 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid session UUID: {e}")))?
182 .or(ctx.session_id)
183 .ok_or_else(|| {
184 wm_core::CoreError::InvalidArgs(
185 "session (UUID string) required: the dispatch carries no session_id and none was passed"
186 .into(),
187 )
188 })?;
189 let mut found = None;
192 for galaxy in wm_core::Galaxy::memory_galaxies() {
193 if let Some(mem) = self.store.get(galaxy, id)? {
194 found = Some((galaxy, mem));
195 break;
196 }
197 }
198 let (galaxy, mut mem) = found.ok_or_else(|| {
199 wm_core::CoreError::NotFound(format!("memory {id} not found in any memory galaxy"))
200 })?;
201 if mem.metadata.corroborated_by.contains(&session) {
202 return Ok(json!({
203 "status": "already_recorded",
204 "id": id,
205 "galaxy": super::common::galaxy_name(galaxy),
206 "corroboration_count": mem.metadata.corroborated_by.len(),
207 }));
208 }
209 mem.metadata.corroborated_by.push(session);
210 self.store.put(galaxy, &mem)?;
212 Ok(json!({
213 "status": "recorded",
214 "id": id,
215 "galaxy": super::common::galaxy_name(galaxy),
216 "session": session,
217 "corroboration_count": mem.metadata.corroborated_by.len(),
218 }))
219 }
220 fn stats(&self) -> &ToolStats {
221 &self.stats
222 }
223}
224
225pub struct MemoryRelateTool {
243 store: Arc<MemoryStore>,
244 stats: ToolStats,
245 effects: EffectRow,
246}
247
248impl MemoryRelateTool {
249 pub fn new(store: Arc<MemoryStore>) -> Self {
250 Self {
251 store,
252 stats: ToolStats::default(),
253 effects: EffectRow {
254 writes: vec![Resource::Galaxy("associations".into())],
255 ..Default::default()
256 },
257 }
258 }
259
260 fn memory_exists(&self, id: uuid::Uuid) -> bool {
262 wm_core::Galaxy::memory_galaxies()
263 .iter()
264 .any(|g| self.store.get(*g, id).ok().flatten().is_some())
265 }
266
267 fn relate_pair(
268 &self,
269 source: uuid::Uuid,
270 target: uuid::Uuid,
271 link_type: LinkType,
272 marker: &str,
273 weight: f32,
274 ) -> wm_core::Result<Value> {
275 if source == target {
276 return Err(wm_core::CoreError::InvalidArgs(
277 "source and target must differ".into(),
278 ));
279 }
280 if !self.memory_exists(source) {
281 return Err(wm_core::CoreError::NotFound(format!(
282 "source memory {source} not found in any memory galaxy"
283 )));
284 }
285 if !self.memory_exists(target) {
286 return Err(wm_core::CoreError::NotFound(format!(
287 "target memory {target} not found in any memory galaxy"
288 )));
289 }
290 let env = self.store.env();
291 let assoc_store = AssociationStore::open(env)?;
292 if let Some(mut existing) = assoc_store.get(env, source, target)? {
293 existing.activate();
295 if existing.link_type != link_type {
296 existing.link_type = link_type;
297 existing.association_type = marker.to_string();
298 }
299 assoc_store.put(env, &existing)?;
300 return Ok(json!({
301 "status": "activated",
302 "source": source.to_string(),
303 "target": target.to_string(),
304 "link_type": existing.link_type.as_str(),
305 "weight": existing.weight,
306 "co_activation_count": existing.co_activation_count,
307 }));
308 }
309 let mut assoc = Association::new(source, target, link_type, weight.clamp(0.0, 1.0));
310 if marker != assoc.association_type {
313 assoc.association_type = marker.to_string();
314 }
315 assoc_store.put(env, &assoc)?;
316 Ok(json!({
317 "status": "created",
318 "source": source.to_string(),
319 "target": target.to_string(),
320 "link_type": assoc.link_type.as_str(),
321 "association_type": assoc.association_type,
322 "weight": assoc.weight,
323 }))
324 }
325
326 fn derive_follows(&self, workspace_tag: Option<&str>) -> wm_core::Result<Value> {
327 let mut sessions: Vec<wm_memory::Memory> = self
329 .store
330 .scan_all(wm_core::Galaxy::Sessions)?
331 .into_iter()
332 .filter(|m| m.metadata.tags.contains(&"start".to_string()))
333 .filter(|m| workspace_tag.is_none_or(|w| m.metadata.tags.contains(&w.to_string())))
334 .collect();
335 sessions.sort_by_key(|m| m.metadata.created_at);
336
337 let env = self.store.env();
338 let assoc_store = AssociationStore::open(env)?;
339 let mut linked = 0usize;
340 let mut activated = 0usize;
341 for pair in sessions.windows(2) {
342 let (earlier, later) = (&pair[0], &pair[1]);
343 let shared = earlier
345 .metadata
346 .tags
347 .iter()
348 .filter(|t| later.metadata.tags.contains(t))
349 .count();
350 #[allow(clippy::suboptimal_flops)]
353 let weight = (0.5 + 0.05 * shared as f32).min(0.9);
354 let source = earlier.metadata.id;
355 let target = later.metadata.id;
356 if let Some(mut existing) = assoc_store.get(env, source, target)? {
357 existing.activate();
358 assoc_store.put(env, &existing)?;
359 activated += 1;
360 } else {
361 let mut assoc = Association::new(source, target, LinkType::Temporal, weight);
362 assoc.association_type = "follows".to_string();
363 assoc_store.put(env, &assoc)?;
364 linked += 1;
365 }
366 }
367 Ok(json!({
368 "status": "success",
369 "sessions": sessions.len(),
370 "workspace_tag": workspace_tag,
371 "follows_created": linked,
372 "follows_activated": activated,
373 }))
374 }
375
376 fn extract_mentions(text: &str) -> Vec<String> {
380 let mut out = Vec::new();
381 let bytes = text.as_bytes();
382 let mut i = 0;
383 while i < bytes.len() {
384 if bytes[i] == b'@' {
385 let mut j = i + 1;
386 while j < bytes.len()
387 && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_' || bytes[j] == b'-')
388 {
389 j += 1;
390 }
391 if j - (i + 1) >= 2 {
392 let token = text[i + 1..j].to_lowercase();
393 if !out.contains(&token) {
394 out.push(token);
395 }
396 }
397 i = j;
398 } else {
399 i += 1;
400 }
401 }
402 out
403 }
404
405 fn extract_iso_dates(text: &str) -> Vec<String> {
409 let mut out = Vec::new();
410 let bytes = text.as_bytes();
411 let mut i = 0;
412 while i + 10 <= bytes.len() {
413 let s = &text[i..i + 10];
414 let is_shape = s.as_bytes()[4] == b'-'
415 && s.as_bytes()[7] == b'-'
416 && s[..4].bytes().all(|b| b.is_ascii_digit())
417 && s[5..7].bytes().all(|b| b.is_ascii_digit())
418 && s[8..10].bytes().all(|b| b.is_ascii_digit());
419 if is_shape && chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
420 let date = s.to_string();
421 if !out.contains(&date) {
422 out.push(date);
423 }
424 i += 10;
425 } else {
426 i += 1;
427 }
428 }
429 out
430 }
431
432 fn derive_mentions(&self, galaxy_arg: Option<&str>) -> wm_core::Result<Value> {
443 let galaxy = super::common::parse_galaxy_or(galaxy_arg, wm_core::Galaxy::Codex)?;
444 let mut mems = self.store.scan(galaxy, 10_000)?;
445 mems.sort_by_key(|m| m.metadata.created_at);
446 let mut created = 0usize;
447 let mut activated = 0usize;
448 let mut scanned = 0usize;
449 for source in &mems {
450 scanned += 1;
451 let haystack = match &source.metadata.title {
452 Some(title) => format!("{}\n{title}", source.content),
453 None => source.content.clone(),
454 };
455 for token in Self::extract_mentions(&haystack).iter().take(20) {
456 let mut targets = 0;
457 for target in &mems {
458 if target.metadata.id == source.metadata.id || targets >= 5 {
459 continue;
460 }
461 let title_hit = target
462 .metadata
463 .title
464 .as_deref()
465 .unwrap_or_default()
466 .to_lowercase()
467 .contains(token);
468 let tag_hit = target
469 .metadata
470 .tags
471 .iter()
472 .any(|t| t.to_lowercase() == *token);
473 if !(title_hit || tag_hit) {
474 continue;
475 }
476 targets += 1;
477 let out = self.relate_pair(
478 source.metadata.id,
479 target.metadata.id,
480 LinkType::Related,
481 "mentions",
482 0.6,
483 )?;
484 if out.get("status").and_then(Value::as_str) == Some("activated") {
485 activated += 1;
486 } else {
487 created += 1;
488 }
489 }
490 }
491 }
492 Ok(json!({
493 "status": "success",
494 "galaxy": super::common::galaxy_name(galaxy),
495 "scanned": scanned,
496 "mentions_created": created,
497 "mentions_activated": activated,
498 }))
499 }
500
501 fn derive_at(&self, galaxy_arg: Option<&str>) -> wm_core::Result<Value> {
508 let galaxy = super::common::parse_galaxy_or(galaxy_arg, wm_core::Galaxy::Codex)?;
509 let mut mems = self.store.scan(galaxy, 10_000)?;
510 mems.sort_by_key(|m| m.metadata.created_at);
511 let mut by_date: std::collections::BTreeMap<String, Vec<uuid::Uuid>> =
512 std::collections::BTreeMap::new();
513 for mem in &mems {
514 for date in Self::extract_iso_dates(&mem.content) {
515 by_date.entry(date).or_default().push(mem.metadata.id);
516 }
517 }
518 let mut created = 0usize;
519 let mut activated = 0usize;
520 let mut dates_linked = 0usize;
521 for ids in by_date.values().filter(|ids| ids.len() >= 2) {
522 dates_linked += 1;
523 for pair in ids.windows(2) {
524 let out = self.relate_pair(pair[0], pair[1], LinkType::Temporal, "at", 0.5)?;
525 if out.get("status").and_then(Value::as_str) == Some("activated") {
526 activated += 1;
527 } else {
528 created += 1;
529 }
530 }
531 }
532 Ok(json!({
533 "status": "success",
534 "galaxy": super::common::galaxy_name(galaxy),
535 "scanned": mems.len(),
536 "dates_linked": dates_linked,
537 "at_created": created,
538 "at_activated": activated,
539 }))
540 }
541}
542
543#[async_trait]
544impl Tool for MemoryRelateTool {
545 fn name(&self) -> &str {
546 "memory.relate"
547 }
548 fn gana(&self) -> Gana {
549 Gana::Net
550 }
551 fn effects(&self) -> &EffectRow {
552 &self.effects
553 }
554 fn input_schema(&self) -> Value {
555 super::common::schema(
556 &json!({
557 "action": super::common::str_prop("relate (default) | derive_follows | derive_mentions | derive_at"),
558 "source": super::common::str_prop("Source memory UUID (relate action)"),
559 "target": super::common::str_prop("Target memory UUID (relate action)"),
560 "link_type": super::common::str_prop("related | extends | contradicts | supersedes | temporal | causal | cascade | follows (default related; follows = temporal + follows marker)"),
561 "weight": super::common::str_prop("Edge weight 0.0-1.0 (default 0.6; follows derivation derives it from shared tags)"),
562 "workspace_tag": super::common::str_prop("derive_follows: only link sessions carrying this tag"),
563 "galaxy": super::common::str_prop("derive_mentions / derive_at: galaxy to scan (default codex)"),
564 }),
565 &[],
566 )
567 }
568 fn description(&self) -> &str {
569 "Relate two memories with a typed, Hebbian edge (related/extends/contradicts/supersedes/temporal/causal/cascade/follows), or derive session --follows--> session threads (action: derive_follows), @mention links (action: derive_mentions), or shared-date links (action: derive_at). Edges feed graph.walk and the recall fusion graph phase."
570 }
571 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
572 match args
573 .get("action")
574 .and_then(Value::as_str)
575 .unwrap_or("relate")
576 {
577 "derive_follows" => {
578 let workspace_tag = args
579 .get("workspace_tag")
580 .and_then(Value::as_str)
581 .filter(|s| !s.is_empty());
582 self.derive_follows(workspace_tag)
583 }
584 "derive_mentions" => self.derive_mentions(args.get("galaxy").and_then(Value::as_str)),
585 "derive_at" => self.derive_at(args.get("galaxy").and_then(Value::as_str)),
586 "relate" => {
587 let source_str = args.get("source").and_then(Value::as_str).ok_or_else(|| {
588 wm_core::CoreError::InvalidArgs("source (UUID string) required".into())
589 })?;
590 let target_str = args.get("target").and_then(Value::as_str).ok_or_else(|| {
591 wm_core::CoreError::InvalidArgs("target (UUID string) required".into())
592 })?;
593 let source = uuid::Uuid::parse_str(source_str).map_err(|e| {
594 wm_core::CoreError::InvalidArgs(format!("invalid source UUID: {e}"))
595 })?;
596 let target = uuid::Uuid::parse_str(target_str).map_err(|e| {
597 wm_core::CoreError::InvalidArgs(format!("invalid target UUID: {e}"))
598 })?;
599 let weight = args
600 .get("weight")
601 .and_then(Value::as_f64)
602 .map_or(0.6, |w| w as f32);
603 let link_str = args
604 .get("link_type")
605 .and_then(Value::as_str)
606 .unwrap_or("related");
607 let (link_type, marker) = if link_str == "follows" {
609 (LinkType::Temporal, "follows")
610 } else {
611 (LinkType::from_str_lossy(link_str), link_str)
612 };
613 self.relate_pair(source, target, link_type, marker, weight)
614 }
615 other => Err(wm_core::CoreError::InvalidArgs(format!(
616 "unknown action '{other}' (relate | derive_follows | derive_mentions | derive_at)"
617 ))),
618 }
619 }
620 fn stats(&self) -> &ToolStats {
621 &self.stats
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use wm_memory::Memory;
629
630 fn test_store() -> Arc<MemoryStore> {
631 let dir = tempfile::tempdir().unwrap();
632 let path = dir.path().join("lmdb");
633 std::fs::create_dir_all(&path).unwrap();
634 std::mem::forget(dir);
636 Arc::new(MemoryStore::open_default(path).unwrap())
637 }
638
639 #[tokio::test]
640 async fn relate_creates_then_activates_the_same_edge() {
641 let store = test_store();
642 let a = Memory::new(wm_core::Galaxy::Codex, "decision alpha".into());
643 let b = Memory::new(wm_core::Galaxy::Codex, "decision beta extends alpha".into());
644 store.put(wm_core::Galaxy::Codex, &a).unwrap();
645 store.put(wm_core::Galaxy::Codex, &b).unwrap();
646
647 let tool = MemoryRelateTool::new(store.clone());
648 let mut ctx = Context::default();
649 let args = json!({
650 "source": a.metadata.id.to_string(),
651 "target": b.metadata.id.to_string(),
652 "link_type": "extends",
653 "weight": 0.7,
654 });
655 let first = tool.call(&mut ctx, args.clone()).await.unwrap();
656 assert_eq!(first["status"], "created");
657 assert_eq!(first["link_type"], "extends");
658 assert!((first["weight"].as_f64().unwrap() - 0.7).abs() < 1e-6);
659
660 let second = tool.call(&mut ctx, args).await.unwrap();
661 assert_eq!(second["status"], "activated");
662 assert_eq!(second["co_activation_count"], 1);
663 assert!(second["weight"].as_f64().unwrap() > 0.7);
665
666 let env = store.env();
668 let assocs = AssociationStore::open(env).unwrap();
669 assert_eq!(assocs.count(env).unwrap(), 1);
670 }
671
672 #[tokio::test]
673 async fn relate_follows_maps_to_temporal_with_marker() {
674 let store = test_store();
675 let s1 = Memory::new(wm_core::Galaxy::Sessions, "session one start".into());
676 let s2 = Memory::new(wm_core::Galaxy::Sessions, "session two start".into());
677 store.put(wm_core::Galaxy::Sessions, &s1).unwrap();
678 store.put(wm_core::Galaxy::Sessions, &s2).unwrap();
679
680 let tool = MemoryRelateTool::new(store);
681 let mut ctx = Context::default();
682 let out = tool
683 .call(
684 &mut ctx,
685 json!({
686 "source": s1.metadata.id.to_string(),
687 "target": s2.metadata.id.to_string(),
688 "link_type": "follows",
689 }),
690 )
691 .await
692 .unwrap();
693 assert_eq!(out["link_type"], "temporal");
694 assert_eq!(out["association_type"], "follows");
695 }
696
697 #[tokio::test]
698 async fn relate_validates_endpoints() {
699 let store = test_store();
700 let a = Memory::new(wm_core::Galaxy::Codex, "existing memory".into());
701 store.put(wm_core::Galaxy::Codex, &a).unwrap();
702 let tool = MemoryRelateTool::new(store);
703 let mut ctx = Context::default();
704 let err = tool
705 .call(
706 &mut ctx,
707 json!({
708 "source": a.metadata.id.to_string(),
709 "target": uuid::Uuid::new_v4().to_string(),
710 }),
711 )
712 .await
713 .unwrap_err();
714 assert!(err.to_string().contains("target memory"), "{err}");
715
716 let err2 = tool
718 .call(
719 &mut ctx,
720 json!({
721 "source": a.metadata.id.to_string(),
722 "target": a.metadata.id.to_string(),
723 }),
724 )
725 .await
726 .unwrap_err();
727 assert!(err2.to_string().contains("must differ"), "{err2}");
728 }
729
730 #[tokio::test]
731 async fn derive_follows_links_consecutive_sessions_in_order() {
732 let store = test_store();
733 let mk = |content: &str, offset_days: i64, workspace: Option<&str>| {
735 let mut m = Memory::new(wm_core::Galaxy::Sessions, content.to_string());
736 m.metadata.tags = vec!["start".to_string(), "session".to_string()];
737 if let Some(w) = workspace {
738 m.metadata.tags.push(w.to_string());
739 }
740 m.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(offset_days);
741 m.metadata.accessed_at = m.metadata.created_at;
742 m
743 };
744 let s1 = mk("first session", 3, Some("wmv5"));
745 let s2 = mk("second session", 2, Some("wmv5"));
746 let s3 = mk("third session", 1, None);
747 for m in [&s3, &s1, &s2] {
749 store.put(wm_core::Galaxy::Sessions, m).unwrap();
750 }
751
752 let tool = MemoryRelateTool::new(store.clone());
753 let mut ctx = Context::default();
754
755 let out = tool
757 .call(
758 &mut ctx,
759 json!({"action": "derive_follows", "workspace_tag": "wmv5"}),
760 )
761 .await
762 .unwrap();
763 assert_eq!(out["sessions"], 2);
764 assert_eq!(out["follows_created"], 1);
765
766 let env = store.env();
767 let assocs = AssociationStore::open(env).unwrap();
768 let from_s1 = assocs.find_from(env, s1.metadata.id).unwrap();
769 assert_eq!(from_s1.len(), 1);
770 assert_eq!(from_s1[0].target, s2.metadata.id);
771 assert_eq!(from_s1[0].link_type, LinkType::Temporal);
772 assert_eq!(from_s1[0].association_type, "follows");
773
774 let out2 = tool
776 .call(&mut ctx, json!({"action": "derive_follows"}))
777 .await
778 .unwrap();
779 assert_eq!(out2["sessions"], 3);
780 assert_eq!(out2["follows_created"], 1);
781 assert_eq!(out2["follows_activated"], 1);
782 assert_eq!(assocs.count(env).unwrap(), 2);
783
784 let chain = assocs.find_to(env, s3.metadata.id).unwrap();
787 assert_eq!(chain[0].source, s2.metadata.id);
788 let earlier = assocs.find_to(env, chain[0].source).unwrap();
789 assert_eq!(earlier[0].source, s1.metadata.id);
790 }
791
792 #[tokio::test]
795 async fn derive_mentions_links_at_tokens_to_titled_memories() {
796 let store = test_store();
797 let mut aria = Memory::new(wm_core::Galaxy::Codex, "persona notes".into());
798 aria.metadata.title = Some("Aria Essays".into());
799 store.put(wm_core::Galaxy::Codex, &aria).unwrap();
800 let mentioner = Memory::new(
801 wm_core::Galaxy::Codex,
802 "as @aria wrote, memory persists".into(),
803 );
804 store.put(wm_core::Galaxy::Codex, &mentioner).unwrap();
805 let lonely = Memory::new(
806 wm_core::Galaxy::Codex,
807 "shouting into the void @nobodyhere".into(),
808 );
809 store.put(wm_core::Galaxy::Codex, &lonely).unwrap();
810
811 let tool = MemoryRelateTool::new(store.clone());
812 let mut ctx = Context::default();
813 let out = tool
814 .call(&mut ctx, json!({"action": "derive_mentions"}))
815 .await
816 .unwrap();
817 assert_eq!(out["status"], "success");
818 assert_eq!(out["scanned"], 3);
819 assert_eq!(out["mentions_created"], 1);
820
821 let env = store.env();
822 let assocs = AssociationStore::open(env).unwrap();
823 let edges = assocs.find_from(env, mentioner.metadata.id).unwrap();
824 assert_eq!(edges.len(), 1);
825 assert_eq!(edges[0].target, aria.metadata.id);
826 assert_eq!(edges[0].link_type, LinkType::Related);
827 assert_eq!(edges[0].association_type, "mentions");
828 assert!(
830 assocs
831 .find_from(env, lonely.metadata.id)
832 .unwrap()
833 .is_empty()
834 );
835
836 let out2 = tool
838 .call(&mut ctx, json!({"action": "derive_mentions"}))
839 .await
840 .unwrap();
841 assert_eq!(out2["mentions_created"], 0);
842 assert_eq!(out2["mentions_activated"], 1);
843 assert_eq!(assocs.count(env).unwrap(), 1);
844 }
845
846 #[tokio::test]
849 async fn derive_at_links_shared_dates_only() {
850 let store = test_store();
851 let a = Memory::new(
852 wm_core::Galaxy::Codex,
853 "shipped the slice on 2026-09-04".into(),
854 );
855 store.put(wm_core::Galaxy::Codex, &a).unwrap();
856 let b = Memory::new(
857 wm_core::Galaxy::Codex,
858 "retro on 2026-09-04 went well".into(),
859 );
860 store.put(wm_core::Galaxy::Codex, &b).unwrap();
861 let c = Memory::new(
862 wm_core::Galaxy::Codex,
863 "impossible date 2026-13-40 here".into(),
864 );
865 store.put(wm_core::Galaxy::Codex, &c).unwrap();
866
867 let tool = MemoryRelateTool::new(store.clone());
868 let mut ctx = Context::default();
869 let out = tool
870 .call(&mut ctx, json!({"action": "derive_at"}))
871 .await
872 .unwrap();
873 assert_eq!(out["status"], "success");
874 assert_eq!(out["dates_linked"], 1);
875 assert_eq!(out["at_created"], 1);
876
877 let env = store.env();
878 let assocs = AssociationStore::open(env).unwrap();
879 let edges = assocs.find_from(env, a.metadata.id).unwrap();
880 assert_eq!(edges.len(), 1);
881 assert_eq!(edges[0].target, b.metadata.id);
882 assert_eq!(edges[0].link_type, LinkType::Temporal);
883 assert_eq!(edges[0].association_type, "at");
884 assert!(assocs.find_from(env, c.metadata.id).unwrap().is_empty());
885 }
886
887 #[tokio::test]
890 async fn corroborate_records_distinct_sessions_idempotently() {
891 let store = test_store();
892 let mem = Memory::new(wm_core::Galaxy::Codex, "a claim".into());
893 let id = mem.metadata.id;
894 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
895
896 let tool = MemoryCorroborateTool::new(store.clone());
897 let mut ctx = Context::default();
898 let s1 = uuid::Uuid::new_v4().to_string();
899 let s2 = uuid::Uuid::new_v4().to_string();
900
901 let first = tool
902 .call(&mut ctx, json!({"id": id.to_string(), "session": s1}))
903 .await
904 .unwrap();
905 assert_eq!(first["status"], "recorded");
906 assert_eq!(first["corroboration_count"], 1);
907
908 let repeat = tool
910 .call(&mut ctx, json!({"id": id.to_string(), "session": s1}))
911 .await
912 .unwrap();
913 assert_eq!(repeat["status"], "already_recorded");
914 assert_eq!(repeat["corroboration_count"], 1);
915
916 let second = tool
918 .call(&mut ctx, json!({"id": id.to_string(), "session": s2}))
919 .await
920 .unwrap();
921 assert_eq!(second["status"], "recorded");
922 assert_eq!(second["corroboration_count"], 2);
923
924 let stored = store.get(wm_core::Galaxy::Codex, id).unwrap().unwrap();
926 assert_eq!(stored.metadata.corroborated_by.len(), 2);
927 }
928
929 #[tokio::test]
930 async fn corroborate_uses_dispatch_session_then_refuses_anonymous() {
931 let store = test_store();
932 let mem = Memory::new(wm_core::Galaxy::Codex, "a claim".into());
933 let id = mem.metadata.id;
934 store.put(wm_core::Galaxy::Codex, &mem).unwrap();
935
936 let tool = MemoryCorroborateTool::new(store.clone());
937 let session = uuid::Uuid::new_v4();
939 let mut ctx = Context {
940 session_id: Some(session),
941 ..Default::default()
942 };
943 let out = tool
944 .call(&mut ctx, json!({"id": id.to_string()}))
945 .await
946 .unwrap();
947 assert_eq!(out["status"], "recorded");
948 assert_eq!(out["session"], session.to_string());
949
950 let mut bare = Context::default();
952 let err = tool
953 .call(&mut bare, json!({"id": id.to_string()}))
954 .await
955 .unwrap_err();
956 assert!(matches!(err, wm_core::CoreError::InvalidArgs(_)));
957
958 let err = tool
960 .call(
961 &mut bare,
962 json!({"id": uuid::Uuid::new_v4().to_string(), "session": session.to_string()}),
963 )
964 .await
965 .unwrap_err();
966 assert!(matches!(err, wm_core::CoreError::NotFound(_)));
967 }
968}