1#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::collections::HashMap;
9use std::fmt::Write;
10use std::sync::Arc;
11use wm_core::{Context, EffectRow, Galaxy, Gana, Resource, Tool, ToolStats};
12use wm_memory::{
13 AssociationStore, MemoryStore, RecallEngine, SearchEngine, episodic::detect_conflicts,
14};
15
16use super::common::{
17 galaxy_name, int_prop, num_prop, parse_galaxy, parse_galaxy_or, schema, str_prop,
18};
19
20fn resolve_memory_across_galaxies(
23 store: &MemoryStore,
24 id: uuid::Uuid,
25) -> Option<(wm_core::Galaxy, wm_memory::Memory)> {
26 for galaxy in wm_core::Galaxy::memory_galaxies() {
27 if let Ok(Some(mem)) = store.get(galaxy, id) {
28 return Some((galaxy, mem));
29 }
30 }
31 None
32}
33
34pub struct MemoryConsolidateTool {
36 store: Arc<MemoryStore>,
37 search: Option<Arc<SearchEngine>>,
38 stats: ToolStats,
39 effects: EffectRow,
40}
41
42impl MemoryConsolidateTool {
43 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
44 Self {
45 store,
46 search,
47 stats: ToolStats::default(),
48 effects: EffectRow {
49 writes: super::common::memory_galaxy_writes(),
50 reads: super::common::memory_galaxy_reads(),
51 destructive: true,
52 ..Default::default()
53 },
54 }
55 }
56}
57
58#[async_trait]
59impl Tool for MemoryConsolidateTool {
60 fn name(&self) -> &str {
61 "memory.consolidate"
62 }
63 fn gana(&self) -> Gana {
64 Gana::Encampment
65 }
66 fn effects(&self) -> &EffectRow {
67 &self.effects
68 }
69 fn description(&self) -> &str {
70 "Deduplicate memories by content_hash within a galaxy"
71 }
72 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
73 let galaxy = args
74 .get("galaxy")
75 .and_then(|v| v.as_str())
76 .unwrap_or("codex");
77 let galaxy = parse_galaxy(galaxy)?;
78 let memories = self.store.scan_all(galaxy)?;
81 let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
82 let mut duplicates = 0u32;
83 for mem in &memories {
84 let hash = &mem.metadata.content_hash;
85 if let Some(existing_id) = seen_hashes.get(hash) {
86 if *existing_id != mem.metadata.id {
87 self.store.delete(galaxy, mem.metadata.id)?;
88 super::common::deindex(self.search.as_deref(), &mem.metadata.id.to_string());
89 duplicates += 1;
90 }
91 } else {
92 seen_hashes.insert(hash.clone(), mem.metadata.id);
93 }
94 }
95 Ok(json!({
96 "status": "success",
97 "galaxy": galaxy_name(galaxy),
98 "scanned": memories.len(),
99 "duplicates_removed": duplicates,
100 }))
101 }
102 fn stats(&self) -> &ToolStats {
103 &self.stats
104 }
105}
106
107pub struct MemoryDecayTool {
109 store: Arc<MemoryStore>,
110 stats: ToolStats,
111 effects: EffectRow,
112}
113
114impl MemoryDecayTool {
115 pub fn new(store: Arc<MemoryStore>) -> Self {
116 Self {
117 store,
118 stats: ToolStats::default(),
119 effects: EffectRow {
120 writes: super::common::memory_galaxy_writes(),
121 reads: super::common::memory_galaxy_reads(),
122 ..Default::default()
123 },
124 }
125 }
126}
127
128#[async_trait]
129impl Tool for MemoryDecayTool {
130 fn name(&self) -> &str {
131 "memory.decay"
132 }
133 fn gana(&self) -> Gana {
134 Gana::WinnowingBasket
135 }
136 fn effects(&self) -> &EffectRow {
137 &self.effects
138 }
139 fn description(&self) -> &str {
140 "Lower importance of old, low-access memories (never deletes)"
141 }
142 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
143 let galaxy = args
144 .get("galaxy")
145 .and_then(|v| v.as_str())
146 .unwrap_or("codex");
147 let galaxy = parse_galaxy(galaxy)?;
148 let threshold = args
149 .get("importance_threshold")
150 .and_then(serde_json::Value::as_f64)
151 .unwrap_or(0.3) as f32;
152 let decay_factor = args
153 .get("decay_factor")
154 .and_then(serde_json::Value::as_f64)
155 .unwrap_or(0.9) as f32;
156 let memories = self.store.scan(galaxy, 10_000)?;
157 let mut decayed = 0u32;
158 for mem in &memories {
159 if mem.metadata.importance < threshold {
160 let mut updated = mem.clone();
161 updated.metadata.importance =
162 (updated.metadata.importance * decay_factor).clamp(0.0, 1.0);
163 if (updated.metadata.importance - mem.metadata.importance).abs() > 0.001 {
164 self.store.put(galaxy, &updated)?;
165 decayed += 1;
166 }
167 }
168 }
169 Ok(json!({
170 "status": "success",
171 "galaxy": galaxy_name(galaxy),
172 "scanned": memories.len(),
173 "decayed": decayed,
174 }))
175 }
176 fn stats(&self) -> &ToolStats {
177 &self.stats
178 }
179}
180
181pub struct MemoryBatchReadTool {
183 store: Arc<MemoryStore>,
184 stats: ToolStats,
185 effects: EffectRow,
186}
187
188impl MemoryBatchReadTool {
189 pub fn new(store: Arc<MemoryStore>) -> Self {
190 Self {
191 store,
192 stats: ToolStats::default(),
193 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
194 }
195 }
196}
197
198#[async_trait]
199impl Tool for MemoryBatchReadTool {
200 fn name(&self) -> &str {
201 "memory.batch_read"
202 }
203 fn gana(&self) -> Gana {
204 Gana::WinnowingBasket
205 }
206 fn effects(&self) -> &EffectRow {
207 &self.effects
208 }
209 fn description(&self) -> &str {
210 "Read multiple memories by ID from a galaxy"
211 }
212 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
213 let galaxy = args
214 .get("galaxy")
215 .and_then(|v| v.as_str())
216 .unwrap_or("codex");
217 let galaxy = parse_galaxy(galaxy)?;
218 let ids = args
219 .get("ids")
220 .and_then(|v| v.as_array())
221 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'ids' array".into()))?;
222 let mut results = Vec::new();
223 let mut misses = 0u32;
224 for id_val in ids {
225 if let Some(id_str) = id_val.as_str() {
226 if let Ok(id) = uuid::Uuid::parse_str(id_str) {
227 match self.store.get(galaxy, id)? {
228 Some(mem)
229 if crate::expansion::common::mcp_visible(&mem)
230 && crate::expansion::common::validity_visible(&mem) =>
231 {
232 results.push(json!({
233 "id": mem.metadata.id,
234 "content": mem.content,
235 "tags": mem.metadata.tags,
236 "importance": mem.metadata.importance,
237 }));
238 }
239 Some(_) => {
242 misses += 1;
243 }
244 None => {
245 misses += 1;
246 }
247 }
248 }
249 }
250 }
251 Ok(json!({
252 "status": "success",
253 "galaxy": galaxy_name(galaxy),
254 "found": results.len(),
255 "misses": misses,
256 "memories": results,
257 }))
258 }
259 fn stats(&self) -> &ToolStats {
260 &self.stats
261 }
262}
263
264pub struct MemoryUpdateTool {
269 store: Arc<MemoryStore>,
270 search: Option<Arc<SearchEngine>>,
271 stats: ToolStats,
272 effects: EffectRow,
273}
274
275impl MemoryUpdateTool {
276 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
277 Self {
278 store,
279 search,
280 stats: ToolStats::default(),
281 effects: EffectRow {
282 writes: super::common::memory_galaxy_writes(),
283 reads: super::common::memory_galaxy_reads(),
284 sandbox: wm_core::Sandbox::StoreScoped,
286 ..Default::default()
287 },
288 }
289 }
290}
291
292#[async_trait]
293impl Tool for MemoryUpdateTool {
294 fn name(&self) -> &str {
295 "memory.update"
296 }
297 fn gana(&self) -> Gana {
298 Gana::Encampment
299 }
300 fn effects(&self) -> &EffectRow {
301 &self.effects
302 }
303 fn description(&self) -> &str {
304 "Update tags, importance, title/topic, or content of an existing memory"
305 }
306 async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
307 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
308 let id_str = args
309 .get("id")
310 .and_then(|v| v.as_str())
311 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
312 let id = uuid::Uuid::parse_str(id_str)
313 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
314 if let Some(search) = &self.search {
315 if search.is_readonly() {
316 return Err(wm_core::CoreError::InvalidArgs(
317 "read-only mode: memory.update disabled (another process owns the index)"
318 .into(),
319 ));
320 }
321 }
322 let mut mem = self.store.get(galaxy, id)?.ok_or_else(|| {
323 wm_core::CoreError::NotFound(format!(
324 "Memory {id} not found in {}",
325 galaxy_name(galaxy)
326 ))
327 })?;
328 let previous_hash = mem.metadata.content_hash.clone();
329 let content_changed = args.get("content").and_then(|v| v.as_str()).is_some();
330 if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
331 mem.metadata.tags = tags
332 .iter()
333 .filter_map(|t| t.as_str().map(String::from))
334 .collect();
335 }
336 if let Some(importance) = args.get("importance").and_then(serde_json::Value::as_f64) {
341 mem.metadata.importance = importance as f32;
342 }
343 if let Some(title) = args.get("title") {
346 mem.metadata.title = title
347 .as_str()
348 .map(str::trim)
349 .filter(|s| !s.is_empty())
350 .map(String::from);
351 }
352 if let Some(topic) = args.get("topic") {
353 mem.metadata.topic = topic
354 .as_str()
355 .map(str::trim)
356 .filter(|s| !s.is_empty())
357 .map(String::from);
358 }
359 if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
360 mem.content = content.to_string();
361 mem.metadata.content_hash = wm_memory::content_hash(content);
364 mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
367 }
368 self.store.put(galaxy, &mem)?;
369
370 let mut revision_disclosure = None;
374 if content_changed {
375 let actor = wm_memory::RevisionActor {
376 session: ctx.session_id.map(|sid| sid.to_string()),
377 user: ctx.user_id.clone(),
378 compartment: ctx.compartment.clone(),
379 };
380 match self.store.record_revision(
381 galaxy,
382 id,
383 &previous_hash,
384 &mem.metadata.content_hash,
385 actor,
386 ) {
387 Ok(rev) => {
388 revision_disclosure = Some(serde_json::json!({
389 "seq": rev.seq,
390 "old_hash": rev.old_hash,
391 "new_hash": rev.new_hash,
392 }));
393 }
394 Err(e) => {
395 tracing::warn!(error = %e, "revision chain record failed for {id_str}");
396 revision_disclosure = Some(serde_json::json!({"record_failed": e.to_string()}));
397 }
398 }
399 }
400
401 let cred_kinds = args
404 .get("content")
405 .and_then(serde_json::Value::as_str)
406 .map(wm_memory::credential_shaped_content)
407 .unwrap_or_default();
408
409 if let Some(search) = &self.search {
411 if let Err(e) = (|| {
412 let mut writer = search.writer()?;
413 search.delete_document(&mut writer, id_str)?;
414 search.add_document(
415 &mut writer,
416 id_str,
417 galaxy_name(galaxy),
418 &mem.content,
419 &mem.metadata.tags,
420 mem.metadata.created_at.timestamp(),
421 )?;
422 search.commit(&mut writer)?;
423 Ok::<(), wm_core::CoreError>(())
424 })() {
425 tracing::warn!("Tantivy re-indexing failed for memory {id_str}: {e}");
426 }
427 }
428
429 let mut response = json!({
430 "status": "success",
431 "id": mem.metadata.id,
432 "galaxy": galaxy_name(galaxy),
433 "tags": mem.metadata.tags,
434 "importance": mem.metadata.importance,
435 "content_hash": mem.metadata.content_hash,
440 });
441 if content_changed {
442 response["prev_content_hash"] = json!(previous_hash);
443 }
444 if let Some(rev) = revision_disclosure {
445 response["revision"] = rev;
446 }
447 if !cred_kinds.is_empty() {
448 response["warnings"] = json!(
449 cred_kinds
450 .iter()
451 .map(|k| format!(
452 "content looks like a credential ({k}) — {}",
453 wm_memory::CREDENTIAL_ADVICE
454 ))
455 .collect::<Vec<String>>()
456 );
457 }
458 Ok(response)
459 }
460 fn stats(&self) -> &ToolStats {
461 &self.stats
462 }
463}
464
465pub struct MemoryRevisionsTool {
472 store: Arc<MemoryStore>,
473 stats: ToolStats,
474 effects: EffectRow,
475}
476
477impl MemoryRevisionsTool {
478 pub fn new(store: Arc<MemoryStore>) -> Self {
479 Self {
480 store,
481 stats: ToolStats::default(),
482 effects: EffectRow {
483 reads: super::common::memory_galaxy_reads(),
484 ..Default::default()
485 },
486 }
487 }
488}
489
490#[async_trait]
491impl Tool for MemoryRevisionsTool {
492 fn name(&self) -> &str {
493 "memory.revisions"
494 }
495 fn gana(&self) -> Gana {
496 Gana::WinnowingBasket
497 }
498 fn effects(&self) -> &EffectRow {
499 &self.effects
500 }
501 fn description(&self) -> &str {
502 "List or verify a memory's content revision chain (tamper evidence)"
503 }
504 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
505 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
506 let id_str = args
507 .get("id")
508 .and_then(|v| v.as_str())
509 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
510 let id = uuid::Uuid::parse_str(id_str)
511 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
512 let action = args
513 .get("action")
514 .and_then(|v| v.as_str())
515 .unwrap_or("list");
516 let revisions = self.store.revisions(galaxy, id)?;
517 match action {
518 "verify" => {
519 let mem = self.store.get(galaxy, id)?.ok_or_else(|| {
520 wm_core::CoreError::NotFound(format!(
521 "Memory {id} not found in {}",
522 galaxy_name(galaxy)
523 ))
524 })?;
525 let report =
526 self.store
527 .verify_revision_chain(galaxy, id, &mem.metadata.content_hash)?;
528 Ok(json!({
529 "status": "success",
530 "id": id,
531 "galaxy": galaxy_name(galaxy),
532 "action": "verify",
533 "valid": report.valid,
534 "entries": report.entries,
535 "matches_head": report.matches_head,
536 "breaks": report.breaks,
537 "note": if report.entries == 0 {
538 "no revisions recorded (never content-updated or pre-S11c)"
539 } else { "" },
540 }))
541 }
542 "list" => Ok(json!({
543 "status": "success",
544 "id": id,
545 "galaxy": galaxy_name(galaxy),
546 "action": "list",
547 "count": revisions.len(),
548 "revisions": revisions,
549 })),
550 other => Err(wm_core::CoreError::InvalidArgs(format!(
551 "Unknown action '{other}' (expected 'list' or 'verify')"
552 ))),
553 }
554 }
555 fn stats(&self) -> &ToolStats {
556 &self.stats
557 }
558}
559
560pub struct MemoryTagTool {
562 store: Arc<MemoryStore>,
563 stats: ToolStats,
564 effects: EffectRow,
565}
566
567impl MemoryTagTool {
568 pub fn new(store: Arc<MemoryStore>) -> Self {
569 Self {
570 store,
571 stats: ToolStats::default(),
572 effects: EffectRow {
573 writes: super::common::memory_galaxy_writes(),
574 reads: super::common::memory_galaxy_reads(),
575 ..Default::default()
576 },
577 }
578 }
579}
580
581#[async_trait]
582impl Tool for MemoryTagTool {
583 fn name(&self) -> &str {
584 "memory.tag"
585 }
586 fn gana(&self) -> Gana {
587 Gana::Net
588 }
589 fn effects(&self) -> &EffectRow {
590 &self.effects
591 }
592 fn description(&self) -> &str {
593 "Add or remove tags from a memory"
594 }
595 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
596 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
597 let id_str = args
598 .get("id")
599 .and_then(|v| v.as_str())
600 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
601 let id = uuid::Uuid::parse_str(id_str)
602 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
603 let mut mem = self
604 .store
605 .get(galaxy, id)?
606 .ok_or_else(|| wm_core::CoreError::NotFound(format!("Memory {id} not found")))?;
607 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
608 let tags = args
609 .get("tags")
610 .and_then(|v| v.as_array())
611 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'tags' array".into()))?;
612 let tag_list: Vec<String> = tags
613 .iter()
614 .filter_map(|t| t.as_str().map(String::from))
615 .collect();
616 match action {
617 "remove" => {
618 mem.metadata.tags.retain(|t| !tag_list.contains(t));
619 }
620 _ => {
621 for t in &tag_list {
622 if !mem.metadata.tags.contains(t) {
623 mem.metadata.tags.push(t.clone());
624 }
625 }
626 }
627 }
628 self.store.put(galaxy, &mem)?;
629 Ok(json!({
630 "status": "success",
631 "id": mem.metadata.id,
632 "action": action,
633 "tags": mem.metadata.tags,
634 }))
635 }
636 fn stats(&self) -> &ToolStats {
637 &self.stats
638 }
639}
640
641pub struct MemoryStatsTool {
643 store: Arc<MemoryStore>,
644 stats: ToolStats,
645 effects: EffectRow,
646}
647
648impl MemoryStatsTool {
649 pub fn new(store: Arc<MemoryStore>) -> Self {
650 Self {
651 store,
652 stats: ToolStats::default(),
653 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
654 }
655 }
656}
657
658#[async_trait]
659impl Tool for MemoryStatsTool {
660 fn name(&self) -> &str {
661 "memory.stats"
662 }
663 fn gana(&self) -> Gana {
664 Gana::WinnowingBasket
665 }
666 fn effects(&self) -> &EffectRow {
667 &self.effects
668 }
669 fn description(&self) -> &str {
670 "Statistics for a galaxy (count, avg importance, tag frequency)"
671 }
672 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
673 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
674 let memories = self.store.scan(galaxy, 10_000)?;
675 let total = memories.len();
676 let avg_importance = if total > 0 {
677 memories.iter().map(|m| m.metadata.importance).sum::<f32>() / total as f32
678 } else {
679 0.0
680 };
681 let mut tag_freq: HashMap<String, u32> = HashMap::new();
682 for mem in &memories {
683 for tag in &mem.metadata.tags {
684 *tag_freq.entry(tag.clone()).or_insert(0) += 1;
685 }
686 }
687 let top_tags: Vec<(String, u32)> = tag_freq.into_iter().filter(|(_, c)| *c >= 2).collect();
688 Ok(json!({
689 "status": "success",
690 "galaxy": galaxy_name(galaxy),
691 "count": total,
692 "avg_importance": (avg_importance * 100.0).round() / 100.0,
693 "tag_clusters": top_tags.len(),
694 "top_tags": top_tags.into_iter().take(10).collect::<Vec<_>>(),
695 }))
696 }
697 fn stats(&self) -> &ToolStats {
698 &self.stats
699 }
700}
701
702pub struct MemoryHybridRecallTool {
705 store: Arc<MemoryStore>,
706 search: Option<Arc<SearchEngine>>,
707 recall: Option<Arc<RecallEngine>>,
708 associations: Option<Arc<AssociationStore>>,
709 stats: ToolStats,
710 effects: EffectRow,
711 route_name: &'static str,
712}
713
714impl MemoryHybridRecallTool {
715 pub fn new(
716 store: Arc<MemoryStore>,
717 search: Option<Arc<SearchEngine>>,
718 recall: Option<Arc<RecallEngine>>,
719 ) -> Self {
720 Self::named("memory.hybrid_recall", store, search, recall)
721 }
722
723 pub fn as_search(
725 store: Arc<MemoryStore>,
726 search: Option<Arc<SearchEngine>>,
727 recall: Option<Arc<RecallEngine>>,
728 ) -> Self {
729 Self::named("memory.search", store, search, recall)
730 }
731
732 #[must_use]
736 pub fn with_associations(mut self, associations: Option<Arc<AssociationStore>>) -> Self {
737 self.associations = associations;
738 self
739 }
740
741 fn named(
742 route_name: &'static str,
743 store: Arc<MemoryStore>,
744 search: Option<Arc<SearchEngine>>,
745 recall: Option<Arc<RecallEngine>>,
746 ) -> Self {
747 Self {
748 store,
749 search,
750 recall,
751 associations: None,
752 stats: ToolStats::default(),
753 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
754 route_name,
755 }
756 }
757}
758
759fn empty_result_hint(store: &MemoryStore, galaxy: Galaxy) -> String {
764 let mut populated: Vec<String> = Vec::new();
765 let mut requested_total = 0usize;
766 for g in Galaxy::memory_galaxies() {
767 if g == galaxy {
768 requested_total = store.count(g).unwrap_or(0);
769 continue;
770 }
771 let n = store.count(g).unwrap_or(0);
772 if n > 0 {
773 populated.push(format!("{} ({})", g.db_name(), n));
774 }
775 }
776 let location = if requested_total == 0 {
777 format!("galaxy '{}' contains no memories", galaxy_name(galaxy))
778 } else {
779 format!(
780 "no matches for this query in '{}' ({} memories)",
781 galaxy_name(galaxy),
782 requested_total
783 )
784 };
785 if populated.is_empty() {
786 format!("{location}; the store is empty")
787 } else {
788 format!(
789 "{}; other galaxies with content: {}. Pass an explicit \"galaxy\" to search there.",
790 location,
791 populated.join(", ")
792 )
793 }
794}
795
796fn empty_result_hint_all(store: &MemoryStore) -> String {
799 let mut populated: Vec<String> = Vec::new();
800 let mut total = 0usize;
801 for g in Galaxy::memory_galaxies() {
802 let n = store.count(g).unwrap_or(0);
803 total += n;
804 if n > 0 {
805 populated.push(format!("{} ({})", g.db_name(), n));
806 }
807 }
808 if populated.is_empty() {
809 "no matches for this query; the store is empty".to_string()
810 } else {
811 format!(
812 "no matches for this query across all memory galaxies ({} total); populated: {}",
813 total,
814 populated.join(", ")
815 )
816 }
817}
818
819#[async_trait]
820impl Tool for MemoryHybridRecallTool {
821 fn name(&self) -> &str {
822 self.route_name
823 }
824 fn gana(&self) -> Gana {
825 Gana::WinnowingBasket
826 }
827 fn effects(&self) -> &EffectRow {
828 &self.effects
829 }
830 fn description(&self) -> &str {
831 "Search memories: hybrid BM25+vector fusion with a real embedder; otherwise the episodic deterministic route, falling back to BM25 full-text. Every result discloses recall_mode (hybrid|episodic|fts). memory.hybrid_recall is a compatibility alias."
832 }
833 fn input_schema(&self) -> Value {
834 schema(
835 &json!({
836 "query": str_prop("Full-text query"),
837 "galaxy": str_prop("Galaxy filter (optional; default: search all memory galaxies, results labeled)"),
838 "limit": int_prop("Maximum results (default 10)"),
839 "min_importance": num_prop("Minimum memory importance (0-1)"),
840 "min_score": num_prop("Absolute BM25 score floor"),
841 "min_score_ratio": num_prop("Relative floor: reject hits below this fraction of the top score"),
842 "min_trust": num_prop("Minimum source_trust (0-1): drop results below this trust floor"),
843 }),
844 &["query"],
845 )
846 }
847 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
848 let galaxy_explicit = args.get("galaxy").and_then(|v| v.as_str()).is_some();
849 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
850 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
851 let limit = args
852 .get("limit")
853 .and_then(serde_json::Value::as_u64)
854 .unwrap_or(10) as usize;
855 let min_importance = args
856 .get("min_importance")
857 .and_then(serde_json::Value::as_f64)
858 .unwrap_or(0.0) as f32;
859 let min_score = args
862 .get("min_score")
863 .and_then(serde_json::Value::as_f64)
864 .map(|v| v as f32)
865 .filter(|v| *v > 0.0);
866 let min_score_ratio = args
870 .get("min_score_ratio")
871 .and_then(serde_json::Value::as_f64)
872 .map(|v| v as f32)
873 .filter(|v| *v >= 0.0 && *v < 1.0)
874 .map_or(Some(0.05), Some);
875 let mut results = Vec::new();
876
877 let trust_weight = std::env::var("WM_TRUST_WEIGHT")
882 .ok()
883 .and_then(|v| v.parse::<f32>().ok())
884 .unwrap_or(0.0)
885 .clamp(0.0, 1.0);
886 let mut result_extra: Option<serde_json::Value> = None;
888 let mut trust_disclosure: Option<serde_json::Value> = None;
889
890 let mut recall_mode = "none";
894 let hybrid_available = self
899 .recall
900 .as_ref()
901 .is_some_and(|recall| recall.embedder_is_real());
902
903 if hybrid_available {
910 let recall = self.recall.as_ref().expect("hybrid_available checked");
911 if !query.is_empty() {
912 let (hybrid_results, conformal) = recall.hybrid_search_with_disclosure(
913 query,
914 limit * 2,
915 galaxy_explicit.then_some(galaxy),
916 );
917 for hr in hybrid_results {
918 if let Ok(Some(mem)) = self.store.get(hr.galaxy, hr.memory_id) {
919 if mem.metadata.importance >= min_importance
920 && crate::expansion::common::mcp_visible(&mem)
921 && crate::expansion::common::validity_visible(&mem)
922 {
923 results.push(json!({
924 "id": mem.metadata.id,
925 "galaxy": mem.metadata.galaxy.db_name(),
926 "content": wm_memory::scrub_text(&mem.content),
927 "importance": mem.metadata.importance,
928 "score": hr.score,
929 "trust_factor": hr.trust_factor,
930 "corroboration": hr.corroboration,
931 "in_conformal_set": hr.in_conformal_set,
932 "bm25_score": hr.bm25_score,
933 "vector_score": hr.vector_score,
934 "trust": mem.metadata.source_trust,
935 "source": "hybrid",
936 }));
937 }
938 }
939 }
940 if let Some(info) = conformal {
944 result_extra = serde_json::to_value(&info).ok();
945 }
946 if trust_weight > 0.0 {
947 trust_disclosure = Some(json!({
948 "wm_trust_weight": trust_weight,
949 "applied_in": "fuse_results",
950 }));
951 }
952 if !results.is_empty() {
953 recall_mode = "hybrid";
954 }
955 }
956 }
957
958 if results.is_empty() && !query.is_empty() && !hybrid_available {
970 const EPISODIC_RECALL_POOL: usize = 100;
971 let pool = limit.max(EPISODIC_RECALL_POOL);
972 let episodic_hits = match self
975 .store
976 .episodic()
977 .search_with_limits(query, pool, pool, false)
978 {
979 Ok(hits) => hits,
980 Err(error) => {
981 tracing::warn!("episodic default-route search failed: {error}");
982 Vec::new()
983 }
984 };
985 for er in episodic_hits {
986 let Some((hit_galaxy, mem)) =
989 resolve_memory_across_galaxies(&self.store, er.record.id)
990 else {
991 continue;
992 };
993 if galaxy_explicit && hit_galaxy != galaxy {
994 continue;
995 }
996 if mem.metadata.importance < min_importance
997 || !crate::expansion::common::mcp_visible(&mem)
998 || !crate::expansion::common::validity_visible(&mem)
999 {
1000 continue;
1001 }
1002 results.push(json!({
1003 "id": mem.metadata.id,
1004 "galaxy": hit_galaxy.db_name(),
1005 "content": wm_memory::scrub_text(&mem.content),
1006 "importance": mem.metadata.importance,
1007 "score": er.score,
1008 "matched_terms": er.matched_terms,
1009 "trust": mem.metadata.source_trust,
1010 "source": "episodic",
1011 }));
1012 }
1013 if !results.is_empty() {
1014 recall_mode = "episodic";
1015 }
1016 }
1017
1018 if results.is_empty() {
1021 if let Some(ref search) = self.search {
1022 if !query.is_empty() {
1023 let opts = wm_memory::SearchOptions {
1024 limit: limit * 2,
1025 min_score,
1026 relative_floor: min_score_ratio,
1027 galaxy: galaxy_explicit.then_some(galaxy),
1035 ..wm_memory::SearchOptions::default()
1036 };
1037 let hits = search.search_opt(query, &opts)?;
1038 for hit in hits {
1039 if let Ok(id) = uuid::Uuid::parse_str(&hit.memory_id) {
1040 let hit_galaxy = if galaxy_explicit {
1044 Some(galaxy)
1045 } else {
1046 wm_core::Galaxy::all()
1047 .into_iter()
1048 .find(|g| g.db_name() == hit.galaxy)
1049 };
1050 let Some(hit_galaxy) = hit_galaxy else {
1051 continue;
1052 };
1053 if let Ok(Some(mem)) = self.store.get(hit_galaxy, id) {
1054 if mem.metadata.importance >= min_importance
1055 && crate::expansion::common::mcp_visible(&mem)
1056 && crate::expansion::common::validity_visible(&mem)
1057 {
1058 results.push(json!({
1059 "id": mem.metadata.id,
1060 "galaxy": hit_galaxy.db_name(),
1061 "content": wm_memory::scrub_text(&mem.content),
1062 "importance": mem.metadata.importance,
1063 "score": wm_memory::trust_weighted_score(
1064 hit.score,
1065 mem.metadata.source_trust,
1066 trust_weight,
1067 ),
1068 "normalized_score": hit.normalized_score,
1069 "trust": mem.metadata.source_trust,
1070 "source": "fts",
1071 }));
1072 if recall_mode == "none" {
1073 recall_mode = "fts";
1074 }
1075 }
1076 }
1077 }
1078 }
1079 }
1080 }
1081 }
1082 if results.is_empty() && query.is_empty() {
1086 let mut memories = self.store.scan(galaxy, 100)?;
1087 memories.sort_by(|a, b| {
1088 b.metadata
1089 .importance
1090 .partial_cmp(&a.metadata.importance)
1091 .unwrap_or(std::cmp::Ordering::Equal)
1092 });
1093 for mem in memories
1094 .iter()
1095 .filter(|m| {
1096 m.metadata.importance >= min_importance
1097 && crate::expansion::common::mcp_visible(m)
1098 && crate::expansion::common::validity_visible(m)
1099 })
1100 .take(limit)
1101 {
1102 results.push(json!({
1103 "id": mem.metadata.id,
1104 "content": &mem.content,
1105 "importance": mem.metadata.importance,
1106 "score": mem.metadata.importance,
1107 "source": "importance",
1108 }));
1109 if recall_mode == "none" {
1110 recall_mode = "importance";
1111 }
1112 }
1113 }
1114 if !results.is_empty() {
1119 if let Some(assoc_store) = &self.associations {
1120 let mut anchors: Vec<(uuid::Uuid, f32)> = results
1121 .iter()
1122 .filter_map(|r| {
1123 let id = r.get("id")?.as_str()?;
1124 uuid::Uuid::parse_str(id).ok().map(|u| {
1125 (
1126 u,
1127 r.get("score")
1128 .and_then(serde_json::Value::as_f64)
1129 .unwrap_or(0.0) as f32,
1130 )
1131 })
1132 })
1133 .collect();
1134 anchors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1135 anchors.dedup_by(|a, b| a.0 == b.0);
1136 anchors.truncate(5);
1137
1138 let mut expansions: Vec<(uuid::Uuid, f32, f32, String, uuid::Uuid)> = Vec::new();
1139 for (seed_id, seed_score) in &anchors {
1140 let mut links = Vec::new();
1141 if let Ok(outgoing) = assoc_store.find_from(self.store.env(), *seed_id) {
1142 links.extend(outgoing);
1143 }
1144 if let Ok(incoming) = assoc_store.find_to(self.store.env(), *seed_id) {
1145 links.extend(incoming);
1146 }
1147 for assoc in links {
1148 if assoc.weight < 0.05 {
1149 continue;
1150 }
1151 let neighbor = if assoc.target == *seed_id {
1152 assoc.source
1153 } else {
1154 assoc.target
1155 };
1156 let score = seed_score * assoc.weight * 0.5;
1157 if score <= 0.0 {
1158 continue;
1159 }
1160 expansions.push((
1161 neighbor,
1162 score,
1163 assoc.weight,
1164 assoc.link_type.as_str().to_string(),
1165 *seed_id,
1166 ));
1167 }
1168 }
1169 expansions.sort_by(|a, b| {
1170 b.1.partial_cmp(&a.1)
1171 .unwrap_or(std::cmp::Ordering::Equal)
1172 .then_with(|| a.0.cmp(&b.0))
1173 });
1174 expansions.dedup_by(|a, b| a.0 == b.0);
1175 expansions.truncate(5);
1176
1177 let direct_ids: Vec<String> = results
1178 .iter()
1179 .filter_map(|r| {
1180 r.get("id")
1181 .and_then(serde_json::Value::as_str)
1182 .map(String::from)
1183 })
1184 .collect();
1185 for (neighbor_id, score, weight, link_type, seed_id) in expansions {
1186 if direct_ids.iter().any(|id| id == &neighbor_id.to_string()) {
1187 continue;
1188 }
1189 let Some((_, mem)) = resolve_memory_across_galaxies(&self.store, neighbor_id)
1190 else {
1191 continue;
1192 };
1193 if mem.metadata.importance < min_importance
1194 || !crate::expansion::common::mcp_visible(&mem)
1195 || !crate::expansion::common::validity_visible(&mem)
1196 {
1197 continue;
1198 }
1199 results.push(json!({
1200 "id": mem.metadata.id,
1201 "content": wm_memory::scrub_text(&mem.content),
1202 "importance": mem.metadata.importance,
1203 "score": score,
1204 "weight": weight,
1205 "link_type": link_type,
1206 "via": seed_id.to_string(),
1207 "source": "association",
1208 }));
1209 }
1210 }
1211 }
1212 if trust_weight > 0.0 {
1215 results.sort_by(|a, b| {
1216 b.get("score")
1217 .and_then(serde_json::Value::as_f64)
1218 .partial_cmp(&a.get("score").and_then(serde_json::Value::as_f64))
1219 .unwrap_or(std::cmp::Ordering::Equal)
1220 });
1221 }
1222 let min_trust = args
1227 .get("min_trust")
1228 .and_then(serde_json::Value::as_f64)
1229 .filter(|v| (0.0..=1.0).contains(v));
1230 let pre_filter = results.len();
1231 if let Some(min) = min_trust {
1232 results.retain(
1233 |r| match r.get("trust").and_then(serde_json::Value::as_f64) {
1234 Some(t) => (t as f32) >= min as f32,
1235 None => true,
1236 },
1237 );
1238 }
1239 let min_trust_filtered = pre_filter - results.len();
1240 results.truncate(limit);
1241 let hint = if results.is_empty() && !query.is_empty() {
1246 Some(if galaxy_explicit {
1247 empty_result_hint(&self.store, galaxy)
1248 } else {
1249 empty_result_hint_all(&self.store)
1250 })
1251 } else {
1252 None
1253 };
1254 let mut out = json!({
1255 "status": "success",
1256 "galaxy": if galaxy_explicit {
1257 serde_json::Value::from(galaxy_name(galaxy))
1258 } else {
1259 serde_json::Value::from("all")
1260 },
1261 "count": results.len(),
1262 "recall_mode": recall_mode,
1263 "results": results,
1264 "hint": hint,
1265 });
1266 if let Some(extra) = result_extra {
1269 out["conformal_set"] = extra;
1270 }
1271 if let Some(td) = trust_disclosure {
1272 out["trust_weighting"] = td;
1273 }
1274 if let Some(min) = min_trust {
1275 out["min_trust"] = json!(min);
1276 out["min_trust_filtered"] = json!(min_trust_filtered);
1277 }
1278 Ok(out)
1279 }
1280 fn stats(&self) -> &ToolStats {
1281 &self.stats
1282 }
1283}
1284
1285pub struct MemoryRecallFeedbackTool {
1295 recall: Option<Arc<RecallEngine>>,
1296 stats: ToolStats,
1297 effects: EffectRow,
1298}
1299
1300impl MemoryRecallFeedbackTool {
1301 #[must_use]
1302 pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1303 Self {
1304 recall,
1305 stats: ToolStats::default(),
1306 effects: EffectRow {
1311 writes: vec![Resource::Filesystem],
1312 ..Default::default()
1313 },
1314 }
1315 }
1316}
1317
1318#[async_trait]
1319impl Tool for MemoryRecallFeedbackTool {
1320 fn name(&self) -> &str {
1321 "memory.recall_feedback"
1322 }
1323 fn gana(&self) -> Gana {
1324 Gana::WinnowingBasket
1325 }
1326 fn effects(&self) -> &EffectRow {
1327 &self.effects
1328 }
1329 fn description(&self) -> &str {
1330 "Record relevance feedback for conformal retrieval calibration (V8 S8). Args: samples (array of {score: number 0-1, relevant: bool}) or score+relevant for a single sample. Requires WM_RECALL_CONFORMAL_ALPHA."
1331 }
1332 fn input_schema(&self) -> Value {
1333 schema(
1334 &json!({
1335 "samples": {"type": "array", "description": "Feedback samples: [{score: 0-1 fused score, relevant: bool}]"},
1336 "score": num_prop("Single-sample fused score (0-1)"),
1337 "relevant": {"type": "boolean", "description": "Single-sample relevance label"},
1338 }),
1339 &[],
1340 )
1341 }
1342 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1343 let Some(ref recall) = self.recall else {
1344 return Ok(json!({
1345 "status": "error",
1346 "message": "no recall engine on this server (hybrid search unavailable) — nothing to calibrate",
1347 }));
1348 };
1349 let mut samples: Vec<(f32, bool)> = Vec::new();
1350 if let Some(list) = args.get("samples").and_then(Value::as_array) {
1351 for s in list {
1352 let score = s.get("score").and_then(Value::as_f64).unwrap_or(-1.0);
1353 let relevant = s.get("relevant").and_then(Value::as_bool);
1354 if !(0.0..=1.0).contains(&score) || relevant.is_none() {
1355 return Err(wm_core::CoreError::InvalidArgs(
1356 "each sample needs score in [0,1] and a boolean 'relevant'".into(),
1357 ));
1358 }
1359 samples.push((score as f32, relevant.unwrap_or(false)));
1360 }
1361 } else if let Some(score) = args.get("score").and_then(Value::as_f64) {
1362 let relevant = args
1363 .get("relevant")
1364 .and_then(Value::as_bool)
1365 .ok_or_else(|| {
1366 wm_core::CoreError::InvalidArgs("'relevant' is required with 'score'".into())
1367 })?;
1368 if !(0.0..=1.0).contains(&score) {
1369 return Err(wm_core::CoreError::InvalidArgs(
1370 "'score' must be within [0,1]".into(),
1371 ));
1372 }
1373 samples.push((score as f32, relevant));
1374 } else {
1375 return Err(wm_core::CoreError::InvalidArgs(
1376 "provide 'samples' (array of {score, relevant}) or a single 'score' + 'relevant'"
1377 .into(),
1378 ));
1379 }
1380
1381 let mut recorded = 0usize;
1382 let mut count = 0usize;
1383 for (score, relevant) in samples {
1384 count = recall.record_relevance_feedback(score, relevant)?;
1385 recorded += 1;
1386 }
1387 let status = recall
1390 .conformal_disclosure(&mut Vec::new())?
1391 .map_or_else(|| "off".into(), |info| info.status);
1392 Ok(json!({
1393 "status": "success",
1394 "recorded": recorded,
1395 "calibration_samples": count,
1396 "conformal_status": status,
1397 }))
1398 }
1399 fn stats(&self) -> &ToolStats {
1400 &self.stats
1401 }
1402}
1403
1404pub struct MemoryEpisodicSearchTool {
1408 store: Arc<MemoryStore>,
1409 stats: ToolStats,
1410 effects: EffectRow,
1411}
1412
1413impl MemoryEpisodicSearchTool {
1414 pub fn new(store: Arc<MemoryStore>) -> Self {
1415 Self {
1416 store,
1417 stats: ToolStats::default(),
1418 effects: EffectRow::read_only(vec![Resource::Galaxy("episodic_records".into())]),
1419 }
1420 }
1421}
1422
1423#[async_trait]
1424impl Tool for MemoryEpisodicSearchTool {
1425 fn name(&self) -> &str {
1426 "memory.episodic_search"
1427 }
1428 fn gana(&self) -> Gana {
1429 Gana::WinnowingBasket
1430 }
1431 fn effects(&self) -> &EffectRow {
1432 &self.effects
1433 }
1434 fn description(&self) -> &str {
1435 "[V6 Experimental] Search explicit episodic records with provenance and lifecycle filtering"
1436 }
1437 fn input_schema(&self) -> Value {
1438 schema(
1439 &json!({
1440 "query": str_prop("Full-text query"),
1441 "limit": int_prop("Maximum results (default 10)"),
1442 "candidate_limit": int_prop("Maximum candidates to score (default 2x limit)"),
1443 "include_historical": {
1444 "type": "boolean",
1445 "description": "Include superseded, revoked, and archived records",
1446 },
1447 "rerank": {
1448 "type": "boolean",
1449 "description": "Enable vector reranking (requires embedder, default false)",
1450 },
1451 "rerank_alpha": {
1452 "type": "number",
1453 "description": "Rerank mode selector (default 0.7): <1.0 hybrid blend weight; >=1.0 near-tie cosine tiebreaker; >=2.0 protected top-K full cosine reorder (recall@limit preserved by construction)",
1454 },
1455 "min_score": {
1456 "type": "number",
1457 "description": "Minimum score threshold; results below this are dropped (abstention). Default 0.0 (no threshold)",
1458 },
1459 "min_coverage": {
1460 "type": "number",
1461 "description": "Minimum query-term coverage ratio (0.0-1.0); results with lower coverage are dropped. E.g. 0.5 requires at least half the query terms to match. Default 0.0 (no threshold)",
1462 },
1463 }),
1464 &["query"],
1465 )
1466 }
1467 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1468 let query = args.get("query").and_then(Value::as_str).unwrap_or("");
1469 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
1470 let candidate_limit =
1471 args.get("candidate_limit")
1472 .and_then(Value::as_u64)
1473 .unwrap_or_else(|| limit.saturating_mul(2) as u64) as usize;
1474 let include_historical = args
1475 .get("include_historical")
1476 .and_then(Value::as_bool)
1477 .unwrap_or(false);
1478 let rerank = args.get("rerank").and_then(Value::as_bool).unwrap_or(false);
1479 let rerank_alpha = args
1480 .get("rerank_alpha")
1481 .and_then(Value::as_f64)
1482 .unwrap_or(0.7) as f32;
1483 let min_score = args
1484 .get("min_score")
1485 .and_then(Value::as_f64)
1486 .map(|v| v as f32);
1487 let min_coverage = args
1488 .get("min_coverage")
1489 .and_then(Value::as_f64)
1490 .map(|v| v as f32);
1491 let query_term_count: usize = {
1495 const STOPWORDS: &[&str] = &[
1496 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
1497 "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
1498 "must", "can", "shall", "to", "of", "in", "on", "at", "by", "for", "with", "about",
1499 "as", "into", "like", "through", "after", "over", "between", "out", "against",
1500 "during", "without", "before", "under", "around", "among", "i", "me", "my", "we",
1501 "us", "our", "you", "your", "he", "him", "his", "she", "her", "it", "its", "they",
1502 "them", "their", "what", "whats", "who", "when", "where", "why", "how", "and",
1503 "or", "but", "not", "no", "nor", "so", "yet", "both", "either", "neither", "this",
1504 "that", "these", "those", "there", "here", "now", "then", "than",
1505 ];
1506 query
1507 .split(|c: char| !c.is_alphanumeric())
1508 .filter(|t| t.len() > 1)
1509 .map(str::to_ascii_lowercase)
1510 .filter(|t| !STOPWORDS.contains(&t.as_str()))
1511 .collect::<std::collections::HashSet<_>>()
1512 .len()
1513 };
1514 let raw_results = if rerank {
1515 self.store.episodic().search_with_rerank(
1516 query,
1517 limit,
1518 candidate_limit,
1519 include_historical,
1520 rerank_alpha,
1521 )?
1522 } else {
1523 self.store.episodic().search_with_limits(
1524 query,
1525 limit,
1526 candidate_limit,
1527 include_historical,
1528 )?
1529 };
1530 let is_count_query = query.to_ascii_lowercase().contains("how many");
1538 let abstain = min_coverage.is_some()
1539 && !is_count_query
1540 && query_term_count >= 3
1541 && !raw_results.iter().any(|hit| hit.matched_terms >= 2);
1542 let visible: Vec<_> = raw_results
1543 .into_iter()
1544 .filter(|hit| !hit.record.is_private && !hit.record.model_exclude)
1545 .filter(|hit| min_score.is_none_or(|ms| hit.score >= ms))
1546 .filter(|_| !abstain)
1547 .take(limit)
1548 .collect();
1549 let conflicts = detect_conflicts(&visible);
1553 let results = visible
1554 .into_iter()
1555 .map(|hit| {
1556 json!({
1557 "id": hit.record.id,
1558 "content": wm_memory::scrub_text(&hit.record.content),
1559 "score": hit.score,
1560 "matched_terms": hit.matched_terms,
1561 "session_id": hit.record.session_id,
1562 "sequence": hit.record.sequence,
1563 "created_at": hit.record.created_at,
1564 "validity": hit.record.validity,
1565 "provenance": hit.record.provenance,
1566 "source": "episodic",
1567 })
1568 })
1569 .collect::<Vec<_>>();
1570 Ok(json!({
1571 "status": "success",
1572 "count": results.len(),
1573 "current_resolution": wm_memory::episodic::is_current_query(query),
1577 "conflicts": conflicts,
1580 "results": results,
1581 }))
1582 }
1583 fn stats(&self) -> &ToolStats {
1584 &self.stats
1585 }
1586}
1587
1588pub struct MemoryAggregateTool {
1601 search: Option<Arc<SearchEngine>>,
1602 store: Arc<MemoryStore>,
1603 stats: ToolStats,
1604 effects: EffectRow,
1605}
1606
1607impl MemoryAggregateTool {
1608 pub fn new(search: Option<Arc<SearchEngine>>, store: Arc<MemoryStore>) -> Self {
1609 Self {
1610 search,
1611 store,
1612 stats: ToolStats::default(),
1613 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1614 }
1615 }
1616}
1617
1618fn session_ordinal(tags: &[String]) -> Option<u64> {
1620 tags.iter().find_map(|tag| {
1621 let rest = tag.strip_prefix("session_")?;
1622 rest.parse::<u64>().ok()
1623 })
1624}
1625
1626fn contains_term(content: &str, term: &str) -> bool {
1629 let lowered = content.to_ascii_lowercase();
1630 let variants = [term.to_string(), strip_suffix(term)];
1631 for variant in &variants {
1632 if variant.len() < 2 {
1633 continue;
1634 }
1635 let mut start = 0;
1636 while let Some(pos) = lowered[start..].find(variant.as_str()) {
1637 let before_ok = pos == 0
1638 || !lowered[start + pos - 1..start + pos]
1639 .chars()
1640 .next()
1641 .is_some_and(char::is_alphanumeric);
1642 let end = start + pos + variant.len();
1643 let after_ok = end >= lowered.len()
1644 || !lowered[end..]
1645 .chars()
1646 .next()
1647 .is_some_and(char::is_alphanumeric);
1648 if before_ok && after_ok {
1649 return true;
1650 }
1651 start += pos + variant.len();
1652 }
1653 }
1654 false
1655}
1656
1657fn strip_suffix(term: &str) -> String {
1660 for suffix in ["ing", "ed", "es", "s"] {
1661 if let Some(stem) = term.strip_suffix(suffix) {
1662 if stem.len() >= 2 {
1663 return stem.to_string();
1664 }
1665 }
1666 }
1667 term.to_string()
1668}
1669
1670#[async_trait]
1671impl Tool for MemoryAggregateTool {
1672 fn name(&self) -> &str {
1673 "memory.aggregate"
1674 }
1675 fn gana(&self) -> Gana {
1676 Gana::WinnowingBasket
1677 }
1678 fn effects(&self) -> &EffectRow {
1679 &self.effects
1680 }
1681 fn description(&self) -> &str {
1682 "Aggregate over memories matching a query: count, distinct session count, or session span (cross-session synthesis)"
1683 }
1684 fn input_schema(&self) -> Value {
1685 schema(
1686 &json!({
1687 "query": str_prop("Full-text query selecting the memories to aggregate over"),
1688 "metric": str_prop("Aggregate metric: count | session_count | session_span"),
1689 "limit": int_prop("Maximum candidates considered (default 50)"),
1690 }),
1691 &["query", "metric"],
1692 )
1693 }
1694 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1695 let query = args
1696 .get("query")
1697 .and_then(Value::as_str)
1698 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
1699 let metric = args
1700 .get("metric")
1701 .and_then(Value::as_str)
1702 .ok_or_else(|| wm_core::CoreError::InvalidArgs("metric (string) required".into()))?;
1703 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1704 let Some(search) = self.search.as_ref() else {
1705 return Err(wm_core::CoreError::Memory(
1706 "search engine unavailable for aggregation".into(),
1707 ));
1708 };
1709
1710 let results = search.search(query, limit)?;
1711 let mut memories = Vec::new();
1713 for r in &results {
1714 let Some(galaxy) = wm_core::Galaxy::from_db_name(&r.galaxy) else {
1715 continue;
1716 };
1717 let Ok(id) = uuid::Uuid::parse_str(&r.memory_id) else {
1718 continue;
1719 };
1720 let Ok(Some(mem)) = self.store.get(galaxy, id) else {
1721 continue;
1722 };
1723 if super::common::mcp_visible(&mem) && super::common::validity_visible(&mem) {
1724 memories.push((r.score, mem));
1725 }
1726 }
1727
1728 let evidence: Vec<Value> = memories
1729 .iter()
1730 .map(|(score, mem)| {
1731 json!({
1732 "memory_id": mem.metadata.id.to_string(),
1733 "score": score,
1734 "content": wm_memory::scrub_text(&mem.content),
1735 "tags": mem.metadata.tags,
1736 })
1737 })
1738 .collect();
1739
1740 let session_tagged: Vec<_> = memories
1749 .iter()
1750 .filter(|(_, mem)| session_ordinal(&mem.metadata.tags).is_some())
1751 .collect();
1752 let (anchored, anchor): (Vec<_>, &str) = if metric == "count" {
1753 (Vec::new(), "none")
1754 } else if session_tagged.len() < 2 {
1755 (session_tagged.clone(), "session_tagged_fallback")
1756 } else {
1757 let terms: Vec<String> = wm_memory::strip_stopwords(query)
1758 .split(|c: char| !c.is_alphanumeric())
1759 .filter(|t| t.len() > 1)
1760 .map(str::to_ascii_lowercase)
1761 .collect();
1762 let mut best: Option<(String, usize)> = None;
1763 for term in &terms {
1764 let count = session_tagged
1765 .iter()
1766 .filter(|(_, mem)| contains_term(&mem.content, term))
1767 .count();
1768 if count == 0 {
1769 continue;
1770 }
1771 let better = best
1772 .as_ref()
1773 .is_none_or(|(_, best_count)| count < *best_count);
1774 if better {
1775 best = Some((term.clone(), count));
1776 }
1777 }
1778 match best {
1779 Some((term, _)) => (
1780 session_tagged
1781 .iter()
1782 .filter(|(_, mem)| contains_term(&mem.content, &term))
1783 .copied()
1784 .collect(),
1785 "rarest_term",
1786 ),
1787 None => (session_tagged.clone(), "session_tagged_fallback"),
1788 }
1789 };
1790
1791 let aggregate = match metric {
1792 "count" => json!({
1793 "metric": "count",
1794 "value": memories.len(),
1795 "content": format!("{} memories", memories.len()),
1796 }),
1797 "session_count" => {
1798 let sessions: std::collections::HashSet<u64> = anchored
1799 .iter()
1800 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
1801 .collect();
1802 json!({
1803 "metric": "session_count",
1804 "value": sessions.len(),
1805 "content": format!("{} distinct sessions", sessions.len()),
1806 })
1807 }
1808 "session_span" => {
1809 let ordinals: Vec<u64> = anchored
1810 .iter()
1811 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
1812 .collect();
1813 if ordinals.is_empty() {
1814 json!({
1815 "metric": "session_span",
1816 "value": null,
1817 "content": "no session-tagged evidence found",
1818 })
1819 } else {
1820 let span = ordinals.iter().max().unwrap() - ordinals.iter().min().unwrap();
1821 json!({
1822 "metric": "session_span",
1823 "value": span,
1824 "unit": "sessions",
1825 "content": format!("{span} sessions"),
1826 })
1827 }
1828 }
1829 other => {
1830 return Err(wm_core::CoreError::InvalidArgs(format!(
1831 "unknown metric '{other}' (count | session_count | session_span)"
1832 )));
1833 }
1834 };
1835
1836 Ok(json!({
1837 "status": "success",
1838 "query": query,
1839 "total": memories.len(),
1840 "session_tagged": session_tagged.len(),
1841 "anchor": anchor,
1842 "limit_hit": results.len() >= limit,
1843 "aggregate": aggregate,
1844 "results": evidence,
1845 }))
1846 }
1847 fn stats(&self) -> &ToolStats {
1848 &self.stats
1849 }
1850}
1851
1852pub struct MemorySortTool {
1854 store: Arc<MemoryStore>,
1855 stats: ToolStats,
1856 effects: EffectRow,
1857}
1858
1859impl MemorySortTool {
1860 pub fn new(store: Arc<MemoryStore>) -> Self {
1861 Self {
1862 store,
1863 stats: ToolStats::default(),
1864 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1865 }
1866 }
1867}
1868
1869#[async_trait]
1870impl Tool for MemorySortTool {
1871 fn name(&self) -> &str {
1872 "memory.sort"
1873 }
1874 fn gana(&self) -> Gana {
1875 Gana::WinnowingBasket
1876 }
1877 fn effects(&self) -> &EffectRow {
1878 &self.effects
1879 }
1880 fn description(&self) -> &str {
1881 "Sort memories by importance, recency, or access count"
1882 }
1883 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1884 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
1885 let sort_by = args
1886 .get("sort_by")
1887 .and_then(|v| v.as_str())
1888 .unwrap_or("importance");
1889 let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
1890 let limit = args
1891 .get("limit")
1892 .and_then(serde_json::Value::as_u64)
1893 .unwrap_or(50) as usize;
1894
1895 let mut memories = self.store.scan(galaxy, 10_000)?;
1896 memories.retain(|m| {
1899 crate::expansion::common::mcp_visible(m)
1900 && crate::expansion::common::validity_visible(m)
1901 });
1902
1903 match sort_by {
1904 "importance" => memories.sort_by(|a, b| {
1905 b.metadata
1906 .importance
1907 .partial_cmp(&a.metadata.importance)
1908 .unwrap_or(std::cmp::Ordering::Equal)
1909 }),
1910 "recency" => memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.created_at)),
1911 "accessed" => {
1912 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.accessed_at));
1913 }
1914 "access_count" => {
1915 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.access_count));
1916 }
1917 _ => {
1918 return Err(wm_core::CoreError::InvalidArgs(format!(
1919 "Unknown sort_by: '{sort_by}'. Use importance, recency, accessed, or access_count"
1920 )));
1921 }
1922 }
1923
1924 if order == "asc" {
1925 memories.reverse();
1926 }
1927
1928 let total = memories.len();
1929 memories.truncate(limit);
1930
1931 let results: Vec<Value> = memories
1932 .iter()
1933 .map(|m| {
1934 json!({
1935 "id": m.metadata.id,
1936 "content": &m.content,
1937 "importance": m.metadata.importance,
1938 "created_at": m.metadata.created_at.to_rfc3339(),
1939 "accessed_at": m.metadata.accessed_at.to_rfc3339(),
1940 "access_count": m.metadata.access_count,
1941 "tags": &m.metadata.tags,
1942 })
1943 })
1944 .collect();
1945
1946 Ok(json!({
1947 "status": "success",
1948 "galaxy": galaxy_name(galaxy),
1949 "sort_by": sort_by,
1950 "order": order,
1951 "total": total,
1952 "returned": results.len(),
1953 "memories": results,
1954 }))
1955 }
1956 fn stats(&self) -> &ToolStats {
1957 &self.stats
1958 }
1959}
1960
1961pub struct MemoryFilterTool {
1963 store: Arc<MemoryStore>,
1964 stats: ToolStats,
1965 effects: EffectRow,
1966}
1967
1968impl MemoryFilterTool {
1969 pub fn new(store: Arc<MemoryStore>) -> Self {
1970 Self {
1971 store,
1972 stats: ToolStats::default(),
1973 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1974 }
1975 }
1976}
1977
1978#[async_trait]
1979impl Tool for MemoryFilterTool {
1980 fn name(&self) -> &str {
1981 "memory.filter"
1982 }
1983 fn gana(&self) -> Gana {
1984 Gana::WinnowingBasket
1985 }
1986 fn effects(&self) -> &EffectRow {
1987 &self.effects
1988 }
1989 fn description(&self) -> &str {
1990 "Filter memories by tags, date range, importance thresholds, and a content query substring"
1991 }
1992 fn input_schema(&self) -> Value {
1993 super::common::schema(
1994 &json!({
1995 "galaxy": super::common::str_prop("Galaxy to filter (default codex)"),
1996 "tags": super::common::str_array_prop("Filter: memories with all of these tags"),
1997 "exclude_tags": super::common::str_array_prop("Filter: drop memories carrying any of these tags"),
1998 "min_importance": super::common::num_prop("Filter: minimum importance (0-1)"),
1999 "max_importance": super::common::num_prop("Filter: maximum importance (0-1)"),
2000 "query": super::common::str_prop("Filter: every whitespace-separated term must appear (case-insensitive) in the content or title"),
2001 "created_after": super::common::str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
2002 "created_before": super::common::str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
2003 "limit": super::common::int_prop("Maximum entries (default 50)"),
2004 "offset": super::common::int_prop("Skip this many matching entries before returning (default 0)"),
2005 }),
2006 &[],
2007 )
2008 }
2009 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2010 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2011 let tags: Vec<String> = args
2012 .get("tags")
2013 .and_then(|v| v.as_array())
2014 .map(|arr| {
2015 arr.iter()
2016 .filter_map(|t| t.as_str().map(String::from))
2017 .collect()
2018 })
2019 .unwrap_or_default();
2020 let exclude_tags: Vec<String> = args
2021 .get("exclude_tags")
2022 .and_then(|v| v.as_array())
2023 .map(|arr| {
2024 arr.iter()
2025 .filter_map(|t| t.as_str().map(String::from))
2026 .collect()
2027 })
2028 .unwrap_or_default();
2029 let min_importance = args
2030 .get("min_importance")
2031 .and_then(serde_json::Value::as_f64)
2032 .unwrap_or(0.0) as f32;
2033 let max_importance = args
2034 .get("max_importance")
2035 .and_then(serde_json::Value::as_f64)
2036 .unwrap_or(1.0) as f32;
2037 let limit = args
2038 .get("limit")
2039 .and_then(serde_json::Value::as_u64)
2040 .unwrap_or(50) as usize;
2041 let offset = args
2042 .get("offset")
2043 .and_then(serde_json::Value::as_u64)
2044 .unwrap_or(0) as usize;
2045 let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
2048 match args.get(name).and_then(|v| v.as_str()) {
2049 Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
2050 .map(|t| Some(t.with_timezone(&chrono::Utc)))
2051 .map_err(|_| {
2052 wm_core::CoreError::InvalidArgs(format!(
2053 "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
2054 ))
2055 }),
2056 _ => Ok(None),
2057 }
2058 };
2059 let created_after = parse_bound("created_after")?;
2060 let created_before = parse_bound("created_before")?;
2061 let query_terms: Vec<String> = args
2065 .get("query")
2066 .and_then(|v| v.as_str())
2067 .map(|q| q.split_whitespace().map(str::to_lowercase).collect())
2068 .unwrap_or_default();
2069
2070 let memories = self.store.scan(galaxy, 10_000)?;
2071
2072 let matched: Vec<&wm_memory::Memory> = memories
2073 .iter()
2074 .filter(|m| {
2075 if !crate::expansion::common::mcp_visible(m) {
2077 return false;
2078 }
2079 if !crate::expansion::common::validity_visible(m) {
2081 return false;
2082 }
2083 if m.metadata.importance < min_importance || m.metadata.importance > max_importance
2084 {
2085 return false;
2086 }
2087 if !tags.is_empty() && !tags.iter().all(|t| m.metadata.tags.contains(t)) {
2088 return false;
2089 }
2090 if exclude_tags
2091 .iter()
2092 .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
2093 {
2094 return false;
2095 }
2096 if let Some(after) = created_after {
2097 if m.metadata.created_at < after {
2098 return false;
2099 }
2100 }
2101 if let Some(before) = created_before {
2102 if m.metadata.created_at > before {
2103 return false;
2104 }
2105 }
2106 if !query_terms.is_empty() {
2107 let haystack = match &m.metadata.title {
2108 Some(title) => format!("{}\n{}", m.content, title).to_lowercase(),
2109 None => m.content.to_lowercase(),
2110 };
2111 if !query_terms.iter().all(|t| haystack.contains(t)) {
2112 return false;
2113 }
2114 }
2115 true
2116 })
2117 .collect();
2118 let filtered: Vec<&&wm_memory::Memory> = matched.iter().skip(offset).take(limit).collect();
2120
2121 let total_scanned = memories.len();
2122 let results: Vec<Value> = filtered
2123 .iter()
2124 .map(|m| {
2125 json!({
2126 "id": m.metadata.id,
2127 "content": &m.content,
2128 "importance": m.metadata.importance,
2129 "tags": &m.metadata.tags,
2130 "created_at": m.metadata.created_at.to_rfc3339(),
2131 })
2132 })
2133 .collect();
2134
2135 Ok(json!({
2136 "status": "success",
2137 "galaxy": galaxy_name(galaxy),
2138 "scanned": total_scanned,
2139 "matched": matched.len(),
2140 "offset": offset,
2141 "returned": results.len(),
2142 "filters": {
2143 "tags": tags,
2144 "exclude_tags": exclude_tags,
2145 "min_importance": min_importance,
2146 "max_importance": max_importance,
2147 "query_terms": query_terms,
2148 "created_after": created_after.map(|t| t.to_rfc3339()),
2149 "created_before": created_before.map(|t| t.to_rfc3339()),
2150 },
2151 "memories": results,
2152 }))
2153 }
2154 fn stats(&self) -> &ToolStats {
2155 &self.stats
2156 }
2157}
2158
2159pub struct MemoryDeduplicateTool {
2161 store: Arc<MemoryStore>,
2162 search: Option<Arc<SearchEngine>>,
2163 stats: ToolStats,
2164 effects: EffectRow,
2165}
2166
2167impl MemoryDeduplicateTool {
2168 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
2169 Self {
2170 store,
2171 search,
2172 stats: ToolStats::default(),
2173 effects: EffectRow {
2174 writes: super::common::memory_galaxy_writes(),
2175 reads: super::common::memory_galaxy_reads(),
2176 destructive: true,
2177 ..Default::default()
2178 },
2179 }
2180 }
2181}
2182
2183#[async_trait]
2184impl Tool for MemoryDeduplicateTool {
2185 fn name(&self) -> &str {
2186 "memory.deduplicate"
2187 }
2188 fn gana(&self) -> Gana {
2189 Gana::WinnowingBasket
2190 }
2191 fn effects(&self) -> &EffectRow {
2192 &self.effects
2193 }
2194 fn description(&self) -> &str {
2195 "Find and merge duplicate memories by content hash or similarity"
2196 }
2197 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2198 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2199 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("hash");
2200 let dry_run = args
2201 .get("dry_run")
2202 .and_then(serde_json::Value::as_bool)
2203 .unwrap_or(true);
2204 let limit = args
2205 .get("limit")
2206 .and_then(serde_json::Value::as_u64)
2207 .unwrap_or(10_000) as usize;
2208
2209 let memories = self.store.scan(galaxy, limit)?;
2210
2211 match mode {
2212 "hash" => {
2213 let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
2214 let mut duplicates: Vec<Value> = Vec::new();
2215
2216 for mem in &memories {
2217 let hash = &mem.metadata.content_hash;
2218 if let Some(existing_id) = seen_hashes.get(hash) {
2219 if *existing_id != mem.metadata.id {
2220 duplicates.push(json!({
2221 "id": mem.metadata.id,
2222 "duplicate_of": existing_id,
2223 "content_preview": mem.content.chars().take(100).collect::<String>(),
2224 "importance": mem.metadata.importance,
2225 }));
2226 if !dry_run {
2227 self.store.delete(galaxy, mem.metadata.id)?;
2228 super::common::deindex(
2229 self.search.as_deref(),
2230 &mem.metadata.id.to_string(),
2231 );
2232 }
2233 }
2234 } else {
2235 seen_hashes.insert(hash.clone(), mem.metadata.id);
2236 }
2237 }
2238
2239 let removed = if dry_run { 0 } else { duplicates.len() };
2240
2241 Ok(json!({
2242 "status": "success",
2243 "galaxy": galaxy_name(galaxy),
2244 "mode": mode,
2245 "dry_run": dry_run,
2246 "scanned": memories.len(),
2247 "duplicates_found": duplicates.len(),
2248 "removed": removed,
2249 "duplicates": duplicates,
2250 }))
2251 }
2252 "content" => {
2253 let mut duplicates: Vec<Value> = Vec::new();
2254 let mut removed_count = 0u32;
2255
2256 for i in 0..memories.len() {
2257 for j in (i + 1)..memories.len() {
2258 if memories[i].content == memories[j].content {
2259 duplicates.push(json!({
2260 "id": memories[j].metadata.id,
2261 "duplicate_of": memories[i].metadata.id,
2262 "content_preview": memories[j].content.chars().take(100).collect::<String>(),
2263 }));
2264 if !dry_run {
2265 self.store.delete(galaxy, memories[j].metadata.id)?;
2266 super::common::deindex(
2267 self.search.as_deref(),
2268 &memories[j].metadata.id.to_string(),
2269 );
2270 removed_count += 1;
2271 }
2272 break;
2273 }
2274 }
2275 }
2276
2277 Ok(json!({
2278 "status": "success",
2279 "galaxy": galaxy_name(galaxy),
2280 "mode": mode,
2281 "dry_run": dry_run,
2282 "scanned": memories.len(),
2283 "duplicates_found": duplicates.len(),
2284 "removed": removed_count,
2285 "duplicates": duplicates,
2286 }))
2287 }
2288 _ => Err(wm_core::CoreError::InvalidArgs(format!(
2289 "Unknown mode: '{mode}'. Use 'hash' or 'content'"
2290 ))),
2291 }
2292 }
2293 fn stats(&self) -> &ToolStats {
2294 &self.stats
2295 }
2296}
2297
2298pub struct MemoryExportTool {
2300 store: Arc<MemoryStore>,
2301 stats: ToolStats,
2302 effects: EffectRow,
2303}
2304
2305impl MemoryExportTool {
2306 pub fn new(store: Arc<MemoryStore>) -> Self {
2307 Self {
2308 store,
2309 stats: ToolStats::default(),
2310 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2311 }
2312 }
2313}
2314
2315#[async_trait]
2316impl Tool for MemoryExportTool {
2317 fn name(&self) -> &str {
2318 "memory.export"
2319 }
2320 fn gana(&self) -> Gana {
2321 Gana::WinnowingBasket
2322 }
2323 fn effects(&self) -> &EffectRow {
2324 &self.effects
2325 }
2326 fn description(&self) -> &str {
2327 "Export memories in JSON, CSV, or Markdown format"
2328 }
2329 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2330 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2331 let format = args
2332 .get("format")
2333 .and_then(|v| v.as_str())
2334 .unwrap_or("json");
2335 let limit = args
2336 .get("limit")
2337 .and_then(serde_json::Value::as_u64)
2338 .unwrap_or(1000) as usize;
2339
2340 let memories = self.store.scan(galaxy, limit)?;
2341
2342 let exported = match format {
2343 "json" => {
2344 let entries: Vec<Value> = memories
2345 .iter()
2346 .map(|m| {
2347 json!({
2348 "id": m.metadata.id,
2349 "content": &m.content,
2350 "tags": &m.metadata.tags,
2351 "importance": m.metadata.importance,
2352 "created_at": m.metadata.created_at.to_rfc3339(),
2353 "access_count": m.metadata.access_count,
2354 })
2355 })
2356 .collect();
2357 serde_json::to_string_pretty(&entries).unwrap_or_default()
2358 }
2359 "csv" => {
2360 let mut csv = String::from("id,content,tags,importance,created_at,access_count\n");
2361 for m in &memories {
2362 let tags = m.metadata.tags.join(";");
2363 let content = m.content.replace('\n', " ").replace('"', "'");
2364 let _ = writeln!(
2365 csv,
2366 "{},{},{},{:.3},{},{}",
2367 m.metadata.id,
2368 content,
2369 tags,
2370 m.metadata.importance,
2371 m.metadata.created_at.to_rfc3339(),
2372 m.metadata.access_count,
2373 );
2374 }
2375 csv
2376 }
2377 "markdown" => {
2378 let mut md = format!("# Memory Export: {}\n\n", galaxy_name(galaxy));
2379 let _ = write!(md, "Total memories: {}\n\n", memories.len());
2380 for m in &memories {
2381 let _ = write!(
2382 md,
2383 "## {}\n\n- **Importance**: {:.2}\n- **Tags**: {}\n- **Created**: {}\n- **Access Count**: {}\n\n{}\n\n---\n\n",
2384 m.metadata.id,
2385 m.metadata.importance,
2386 m.metadata.tags.join(", "),
2387 m.metadata.created_at.to_rfc3339(),
2388 m.metadata.access_count,
2389 m.content,
2390 );
2391 }
2392 md
2393 }
2394 _ => {
2395 return Err(wm_core::CoreError::InvalidArgs(format!(
2396 "Unknown format: '{format}'. Use json, csv, or markdown"
2397 )));
2398 }
2399 };
2400
2401 Ok(json!({
2402 "status": "success",
2403 "galaxy": galaxy_name(galaxy),
2404 "format": format,
2405 "count": memories.len(),
2406 "export": exported,
2407 }))
2408 }
2409 fn stats(&self) -> &ToolStats {
2410 &self.stats
2411 }
2412}
2413
2414#[cfg(test)]
2415mod tests {
2416 use super::*;
2417 use wm_core::{EpisodicKind, EpisodicRecord, Galaxy, Provenance, ProvenanceSource};
2418 use wm_memory::{Association, AssociationStore, LinkType, Memory, MemoryStore};
2419
2420 fn test_store() -> Arc<MemoryStore> {
2421 let dir = tempfile::tempdir().unwrap();
2422 Arc::new(MemoryStore::open_default(dir.path()).unwrap())
2423 }
2424
2425 fn populate_memories(store: &Arc<MemoryStore>, galaxy: Galaxy) {
2426 let mut m1 = Memory::new(galaxy, "First memory about rust".into());
2427 m1.metadata.importance = 0.9;
2428 m1.metadata.tags = vec!["rust".into(), "programming".into()];
2429 let _ = store.put(galaxy, &m1);
2430
2431 let mut m2 = Memory::new(galaxy, "Second memory about python".into());
2432 m2.metadata.importance = 0.5;
2433 m2.metadata.tags = vec!["python".into()];
2434 let _ = store.put(galaxy, &m2);
2435
2436 let mut m3 = Memory::new(galaxy, "Third memory about rust".into());
2437 m3.metadata.importance = 0.3;
2438 m3.metadata.tags = vec!["rust".into(), "tutorial".into()];
2439 let _ = store.put(galaxy, &m3);
2440 }
2441
2442 #[tokio::test]
2443 async fn episodic_search_filters_private_records() {
2444 let store = test_store();
2445 let public = EpisodicRecord::new(
2446 None,
2447 1,
2448 EpisodicKind::Observation,
2449 "public retrieval evidence",
2450 Provenance::new(ProvenanceSource::User),
2451 );
2452 let private = EpisodicRecord::new(
2453 None,
2454 2,
2455 EpisodicKind::Observation,
2456 "private retrieval evidence",
2457 Provenance::new(ProvenanceSource::User),
2458 )
2459 .with_visibility(true, false);
2460 store.episodic().append(&public).unwrap();
2461 store.episodic().append(&private).unwrap();
2462
2463 let tool = MemoryEpisodicSearchTool::new(store);
2464 let mut ctx = Context::default();
2465 let result = tool
2466 .call(
2467 &mut ctx,
2468 json!({"query": "retrieval evidence", "limit": 10}),
2469 )
2470 .await
2471 .unwrap();
2472 assert_eq!(result["count"], 1);
2473 assert_eq!(result["results"][0]["id"], json!(public.id));
2474 }
2475
2476 fn mirror_memory(
2479 store: &Arc<MemoryStore>,
2480 mem: &Memory,
2481 session: Option<uuid::Uuid>,
2482 sequence: u64,
2483 ) {
2484 use wm_core::EpisodicCapturePolicy;
2485 let record = EpisodicRecord::new(
2486 session,
2487 sequence,
2488 EpisodicKind::Observation,
2489 mem.content.clone(),
2490 Provenance::new(ProvenanceSource::User),
2491 )
2492 .with_id(mem.metadata.id)
2493 .with_visibility(mem.metadata.is_private, mem.metadata.model_exclude);
2494 store
2495 .episodic()
2496 .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
2497 .unwrap();
2498 }
2499
2500 fn default_search_tool(
2501 store: Arc<MemoryStore>,
2502 search: Option<Arc<SearchEngine>>,
2503 ) -> MemoryHybridRecallTool {
2504 MemoryHybridRecallTool::as_search(store, search, None)
2505 }
2506
2507 #[tokio::test]
2508 async fn default_route_prefers_episodic_and_discloses_mode() {
2509 let (_dir, store, search) = hybrid_fixture();
2513 let needle = Memory::new(
2514 Galaxy::Codex,
2515 "Kotlin coroutine budget meeting notes".into(),
2516 );
2517 let needle_id = needle.metadata.id;
2518 let other = Memory::new(Galaxy::Codex, "Grocery list eggs and flour".into());
2519 store.put(Galaxy::Codex, &needle).unwrap();
2520 store.put(Galaxy::Codex, &other).unwrap();
2521 mirror_memory(&store, &needle, None, 1);
2522 mirror_memory(&store, &other, None, 2);
2523
2524 let tool = default_search_tool(store.clone(), Some(search));
2525 let mut ctx = Context::default();
2526 let v = tool
2527 .call(
2528 &mut ctx,
2529 json!({"query": "kotlin coroutine budget", "limit": 10}),
2530 )
2531 .await
2532 .unwrap();
2533 assert_eq!(v["recall_mode"], "episodic");
2534 assert_eq!(v["results"][0]["source"], "episodic");
2535 assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
2536 assert!(v["results"][0]["score"].as_f64().unwrap() > 0.0);
2537 }
2538
2539 #[tokio::test]
2540 async fn default_route_matches_the_episodic_machinery_ranking() {
2541 let (_dir, store, search) = hybrid_fixture();
2545 let contents = [
2546 "Deployed the telemetry agent on Tuesday",
2547 "Cancun trip booked for the twelfth",
2548 "Telemetry agent rollout postponed to Friday",
2549 "Deadline for the quarterly report moved",
2550 ];
2551 let mut memories: Vec<(uuid::Uuid, &str)> = Vec::new();
2552 for (i, content) in contents.iter().enumerate() {
2553 let mem = Memory::new(Galaxy::Codex, (*content).to_string());
2554 memories.push((mem.metadata.id, content));
2555 store.put(Galaxy::Codex, &mem).unwrap();
2556 mirror_memory(&store, &mem, None, i as u64 + 1);
2557 }
2558 let query = "when was the telemetry agent deployed";
2559
2560 let default_tool = default_search_tool(store.clone(), Some(search.clone()));
2561 let mut ctx = Context::default();
2562 let default_v = default_tool
2563 .call(&mut ctx, json!({"query": query, "limit": 10}))
2564 .await
2565 .unwrap();
2566 let episodic_tool = MemoryEpisodicSearchTool::new(store);
2567 let episodic_v = episodic_tool
2568 .call(&mut ctx, json!({"query": query, "limit": 10}))
2569 .await
2570 .unwrap();
2571 assert_eq!(
2572 default_v["results"][0]["id"], episodic_v["results"][0]["id"],
2573 "default route must rank exactly like the episodic machinery"
2574 );
2575 let top = memories
2576 .iter()
2577 .find(|(id, _)| id.to_string() == default_v["results"][0]["id"])
2578 .map(|(_, c)| *c)
2579 .unwrap();
2580 assert_eq!(top, "Deployed the telemetry agent on Tuesday");
2581 }
2582
2583 #[tokio::test]
2584 async fn default_route_falls_back_to_fts_when_episodic_yields_nothing() {
2585 let (_dir, store, search) = hybrid_fixture();
2588 let mem = Memory::new(Galaxy::Codex, "Zebra quotas revised upward".into());
2589 let id = mem.metadata.id;
2590 store.put(Galaxy::Codex, &mem).unwrap();
2591 {
2592 let mut writer = search.writer().unwrap();
2593 search
2594 .add_document(
2595 &mut writer,
2596 &id.to_string(),
2597 "codex",
2598 "Zebra quotas revised upward",
2599 &mem.metadata.tags,
2600 mem.metadata.created_at.timestamp(),
2601 )
2602 .unwrap();
2603 search.commit(&mut writer).unwrap();
2604 }
2605
2606 let tool = default_search_tool(store, Some(search));
2607 let mut ctx = Context::default();
2608 let v = tool
2609 .call(&mut ctx, json!({"query": "zebra quotas", "limit": 10}))
2610 .await
2611 .unwrap();
2612 assert_eq!(v["recall_mode"], "fts");
2613 assert_eq!(v["results"][0]["source"], "fts");
2614 assert_eq!(v["results"][0]["id"], json!(id.to_string()));
2615 }
2616
2617 #[tokio::test]
2618 async fn episodic_default_route_respects_galaxy_filter() {
2619 let (_dir, store, search) = hybrid_fixture();
2620 let in_galaxy = Memory::new(Galaxy::Codex, "Marble fountain restoration plan".into());
2621 let other_galaxy =
2622 Memory::new(Galaxy::Sessions, "Marble fountain restoration notes".into());
2623 store.put(Galaxy::Codex, &in_galaxy).unwrap();
2624 store.put(Galaxy::Sessions, &other_galaxy).unwrap();
2625 mirror_memory(&store, &in_galaxy, None, 1);
2626 mirror_memory(&store, &other_galaxy, None, 2);
2627
2628 let tool = default_search_tool(store, Some(search));
2629 let mut ctx = Context::default();
2630 let v = tool
2631 .call(
2632 &mut ctx,
2633 json!({"query": "marble fountain", "galaxy": "sessions", "limit": 10}),
2634 )
2635 .await
2636 .unwrap();
2637 assert_eq!(v["recall_mode"], "episodic");
2638 for r in v["results"].as_array().unwrap() {
2639 assert_eq!(r["galaxy"], "sessions", "galaxy filter must hold");
2640 }
2641 assert_eq!(
2642 v["results"][0]["id"],
2643 json!(other_galaxy.metadata.id.to_string())
2644 );
2645 }
2646
2647 #[tokio::test]
2648 async fn episodic_default_route_filters_private_and_stale() {
2649 let (_dir, store, search) = hybrid_fixture();
2650 let public = Memory::new(
2651 Galaxy::Codex,
2652 "Lighthouse maintenance schedule confirmed".into(),
2653 );
2654 let mut private = Memory::new(Galaxy::Codex, "Lighthouse access code renewal".into());
2655 private.metadata.is_private = true;
2656 let stale = Memory::new(Galaxy::Codex, "Lighthouse inspection legacy draft".into());
2657 let stale_id = stale.metadata.id;
2658 store.put(Galaxy::Codex, &public).unwrap();
2659 store.put(Galaxy::Codex, &private).unwrap();
2660 store.put(Galaxy::Codex, &stale).unwrap();
2661 mirror_memory(&store, &public, None, 1);
2662 mirror_memory(&store, &private, None, 2);
2663 mirror_memory(&store, &stale, None, 3);
2664 store.delete(Galaxy::Codex, stale_id).unwrap();
2667
2668 let tool = default_search_tool(store, Some(search));
2669 let mut ctx = Context::default();
2670 let v = tool
2671 .call(&mut ctx, json!({"query": "lighthouse", "limit": 10}))
2672 .await
2673 .unwrap();
2674 assert_eq!(v["recall_mode"], "episodic");
2675 let ids: Vec<&str> = v["results"]
2676 .as_array()
2677 .unwrap()
2678 .iter()
2679 .filter_map(|r| r["id"].as_str())
2680 .collect();
2681 assert!(
2682 !ids.contains(&private.metadata.id.to_string().as_str()),
2683 "private memories must never surface on the default route"
2684 );
2685 assert!(
2686 !ids.contains(&stale_id.to_string().as_str()),
2687 "episodic records without a live v5 memory must be skipped"
2688 );
2689 assert!(!ids.is_empty(), "the public hit must still surface");
2690 }
2691
2692 #[tokio::test]
2693 async fn memory_sort_by_importance_desc() {
2694 let store = test_store();
2695 populate_memories(&store, Galaxy::Codex);
2696 let tool = MemorySortTool::new(store);
2697 let mut ctx = Context::default();
2698 let v = tool
2699 .call(&mut ctx, json!({"sort_by": "importance", "order": "desc"}))
2700 .await
2701 .unwrap();
2702 assert_eq!(v["status"], "success");
2703 assert_eq!(v["returned"], 3);
2704 let mems = v["memories"].as_array().unwrap();
2705 assert!(mems[0]["importance"].as_f64().unwrap() >= mems[1]["importance"].as_f64().unwrap());
2706 }
2707
2708 #[tokio::test]
2709 async fn memory_update_cannot_mutate_tier() {
2710 let store = test_store();
2714 let mem = Memory::new(Galaxy::Codex, "tier is not client-settable".into());
2715 let id = mem.metadata.id;
2716 store.put(Galaxy::Codex, &mem).unwrap();
2717
2718 let tool = MemoryUpdateTool::new(store.clone(), None);
2719 let mut ctx = Context::default();
2720 let v = tool
2721 .call(
2722 &mut ctx,
2723 json!({"galaxy": "codex", "id": id.to_string(), "tier": "archival", "tags": ["x"]}),
2724 )
2725 .await
2726 .unwrap();
2727 assert_eq!(v["status"], "success");
2728
2729 let after = store.get(Galaxy::Codex, id).unwrap().unwrap();
2730 assert_eq!(
2731 after.metadata.tier,
2732 wm_memory::Tier::Working,
2733 "memory.update must never move the tier"
2734 );
2735 assert_eq!(
2736 after.metadata.tags,
2737 vec!["x".to_string()],
2738 "whitelisted fields still apply"
2739 );
2740 }
2741
2742 #[tokio::test]
2743 async fn empty_search_hints_at_populated_galaxies() {
2744 let (_dir, store, search) = hybrid_fixture();
2749 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
2750 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
2751 let mut ctx = Context::default();
2752 let v = tool
2753 .call(&mut ctx, json!({"query": "gate plan"}))
2754 .await
2755 .unwrap();
2756 assert_eq!(
2757 v["count"], 1,
2758 "unfiltered search must find cross-galaxy content: {v}"
2759 );
2760 assert_eq!(v["galaxy"], "all");
2761 assert_eq!(v["results"][0]["galaxy"], "sessions");
2762 assert!(v["hint"].is_null());
2763
2764 let v2 = tool
2766 .call(
2767 &mut ctx,
2768 json!({"query": "zzz-no-match", "galaxy": "sessions"}),
2769 )
2770 .await
2771 .unwrap();
2772 assert_eq!(v2["count"], 0);
2773 let hint2 = v2["hint"].as_str().unwrap();
2774 assert!(hint2.contains("no matches for this query"), "{hint2}");
2775
2776 let v3 = tool
2779 .call(&mut ctx, json!({"query": "zzz-no-match"}))
2780 .await
2781 .unwrap();
2782 assert_eq!(v3["count"], 0);
2783 let hint3 = v3["hint"].as_str().expect("hint present on empty result");
2784 assert!(hint3.contains("across all memory galaxies"), "{hint3}");
2785 }
2786
2787 #[tokio::test]
2788 async fn unfiltered_search_labels_hits_from_every_galaxy() {
2789 let (_dir, store, search) = hybrid_fixture();
2794 index_memory(
2795 &store,
2796 &search,
2797 Galaxy::Sessions,
2798 "lineage ledger phase four",
2799 );
2800 index_memory(
2801 &store,
2802 &search,
2803 Galaxy::Codex,
2804 "lineage ledger codex mirror note",
2805 );
2806 index_memory(&store, &search, Galaxy::Dreams, "lineage ledger dream echo");
2807 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
2808 let mut ctx = Context::default();
2809 let v = tool
2810 .call(&mut ctx, json!({"query": "lineage ledger", "limit": 10}))
2811 .await
2812 .unwrap();
2813 assert_eq!(v["count"], 3, "got: {v}");
2814 let galaxies: Vec<&str> = v["results"]
2815 .as_array()
2816 .unwrap()
2817 .iter()
2818 .map(|r| r["galaxy"].as_str().unwrap())
2819 .collect();
2820 assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
2821 assert!(galaxies.contains(&"codex"), "got: {galaxies:?}");
2822 assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
2823
2824 let v2 = tool
2826 .call(
2827 &mut ctx,
2828 json!({"query": "lineage ledger", "galaxy": "dreams"}),
2829 )
2830 .await
2831 .unwrap();
2832 assert_eq!(v2["count"], 1, "got: {v2}");
2833 assert_eq!(v2["results"][0]["galaxy"], "dreams");
2834 }
2835
2836 #[tokio::test]
2837 async fn successful_search_carries_no_hint() {
2838 let (_dir, store, search) = hybrid_fixture();
2839 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
2840 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
2841 let mut ctx = Context::default();
2842 let v = tool
2843 .call(
2844 &mut ctx,
2845 json!({"query": "gate plan", "galaxy": "sessions"}),
2846 )
2847 .await
2848 .unwrap();
2849 assert_eq!(v["count"], 1);
2850 assert!(v["hint"].is_null());
2851 }
2852
2853 #[tokio::test]
2854 async fn associative_expansion_surfaces_linked_memory() {
2855 let dir = tempfile::tempdir().unwrap();
2859 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
2860 let tantivy_dir = dir.path().join("tantivy");
2861 std::fs::create_dir_all(&tantivy_dir).unwrap();
2862 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
2863 index_memory(
2864 &store,
2865 &search,
2866 Galaxy::Codex,
2867 "gate plan for the v7 alpha release",
2868 );
2869 let mut linked = Memory::new(
2870 Galaxy::Codex,
2871 "backup automation runs nightly at 03:30".into(),
2872 );
2873 linked.metadata.importance = 0.7;
2874 let linked_id = linked.metadata.id;
2875 store.put(Galaxy::Codex, &linked).unwrap();
2876 search
2877 .writer()
2878 .and_then(|mut w| {
2879 search.add_document(
2880 &mut w,
2881 &linked_id.to_string(),
2882 "codex",
2883 &linked.content,
2884 &linked.metadata.tags,
2885 linked.metadata.created_at.timestamp(),
2886 )?;
2887 search.commit(&mut w)
2888 })
2889 .unwrap();
2890
2891 let assoc = Association::new(
2893 find_id(&store, "gate plan"),
2894 linked_id,
2895 LinkType::Extends,
2896 0.8,
2897 );
2898 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
2899 associations.put(store.env(), &assoc).unwrap();
2900
2901 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
2902 .with_associations(Some(associations));
2903 let mut ctx = Context::default();
2904 let v = tool
2905 .call(&mut ctx, json!({"query": "gate plan alpha release"}))
2906 .await
2907 .unwrap();
2908 assert_eq!(v["count"], 2, "direct hit + associated memory: {v}");
2909 let assoc_hit = v["results"]
2910 .as_array()
2911 .unwrap()
2912 .iter()
2913 .find(|r| r["source"] == "association")
2914 .expect("association-sourced result present");
2915 assert_eq!(assoc_hit["id"], json!(linked_id.to_string()));
2916 assert_eq!(assoc_hit["link_type"], "extends");
2917 assert!(assoc_hit["via"].is_string());
2918 assert!(assoc_hit["weight"].as_f64().unwrap() > 0.7);
2919 }
2920
2921 fn find_id(store: &MemoryStore, needle: &str) -> uuid::Uuid {
2922 store
2923 .scan(Galaxy::Codex, 100)
2924 .unwrap()
2925 .into_iter()
2926 .find(|m| m.content.contains(needle))
2927 .map(|m| m.metadata.id)
2928 .unwrap()
2929 }
2930
2931 #[tokio::test]
2932 async fn associative_expansion_skips_private_and_dedupes() {
2933 let dir = tempfile::tempdir().unwrap();
2934 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
2935 let mut a = Memory::new(Galaxy::Codex, "quarterly revenue planning notes".into());
2936 a.metadata.importance = 0.8;
2937 let a_id = a.metadata.id;
2938 store.put(Galaxy::Codex, &a).unwrap();
2939 let mut private = Memory::new(Galaxy::Codex, "private salary bands".into());
2941 private.metadata.is_private = true;
2942 private.metadata.importance = 0.8;
2943 store.put(Galaxy::Codex, &private).unwrap();
2944 let mut b = Memory::new(Galaxy::Codex, "hiring plan for next quarter".into());
2946 b.metadata.importance = 0.7;
2947 let b_id = b.metadata.id;
2948 store.put(Galaxy::Codex, &b).unwrap();
2949
2950 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
2951 associations
2952 .put(
2953 store.env(),
2954 &Association::new(a_id, private.metadata.id, LinkType::Related, 0.9),
2955 )
2956 .unwrap();
2957 associations
2958 .put(
2959 store.env(),
2960 &Association::new(a_id, b_id, LinkType::Related, 0.9),
2961 )
2962 .unwrap();
2963 associations
2964 .put(
2965 store.env(),
2966 &Association::new(b_id, a_id, LinkType::Related, 0.9),
2967 )
2968 .unwrap();
2969
2970 let tool = MemoryHybridRecallTool::as_search(store.clone(), None, None)
2971 .with_associations(Some(associations.clone()));
2972 let mut ctx = Context::default();
2973 let _ = &tool;
2977 let tantivy_dir = dir.path().join("tantivy");
2978 std::fs::create_dir_all(&tantivy_dir).unwrap();
2979 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
2980 for (content, id) in [
2981 ("quarterly revenue planning notes", a_id),
2982 ("private salary bands", private.metadata.id),
2983 ("hiring plan for next quarter", b_id),
2984 ] {
2985 search
2986 .writer()
2987 .and_then(|mut w| {
2988 search.add_document(&mut w, &id.to_string(), "codex", content, &[], 0)?;
2989 search.commit(&mut w)
2990 })
2991 .unwrap();
2992 }
2993 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
2994 .with_associations(Some(associations));
2995 let v = tool
2996 .call(&mut ctx, json!({"query": "quarterly revenue planning"}))
2997 .await
2998 .unwrap();
2999 let ids: Vec<&str> = v["results"]
3000 .as_array()
3001 .unwrap()
3002 .iter()
3003 .filter_map(|r| r["id"].as_str())
3004 .collect();
3005 assert!(
3006 !ids.iter().any(|id| *id == private.metadata.id.to_string()),
3007 "private memory must not surface via association: {ids:?}"
3008 );
3009 assert_eq!(
3010 ids.iter().filter(|id| **id == b_id.to_string()).count(),
3011 1,
3012 "neighbor linked both directions appears exactly once: {ids:?}"
3013 );
3014 }
3015
3016 #[tokio::test]
3017 async fn memory_update_content_recomputes_hash() {
3018 let store = test_store();
3019 let mem = Memory::new(Galaxy::Codex, "original text".into());
3020 store.put(Galaxy::Codex, &mem).unwrap();
3021 let id = mem.metadata.id;
3022 let original_hash = mem.metadata.content_hash.clone();
3023
3024 let tool = MemoryUpdateTool::new(store.clone(), None);
3025 let v = tool
3026 .call(
3027 &mut Context::default(),
3028 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3029 )
3030 .await
3031 .unwrap();
3032 assert_eq!(v["status"], "success");
3033
3034 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3037 assert_eq!(stored.content, "changed text");
3038 assert_eq!(
3039 stored.metadata.content_hash,
3040 wm_memory::content_hash("changed text")
3041 );
3042 assert_ne!(stored.metadata.content_hash, original_hash);
3043 }
3044
3045 #[tokio::test]
3046 async fn memory_update_discloses_hash_timeline() {
3047 let store = test_store();
3051 let mem = Memory::new(Galaxy::Codex, "original text".into());
3052 store.put(Galaxy::Codex, &mem).unwrap();
3053 let id = mem.metadata.id;
3054 let original_hash = mem.metadata.content_hash.clone();
3055 let tool = MemoryUpdateTool::new(store.clone(), None);
3056 let mut ctx = Context::default();
3057
3058 let v = tool
3059 .call(
3060 &mut ctx,
3061 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3062 )
3063 .await
3064 .unwrap();
3065 assert_eq!(
3066 v["content_hash"],
3067 json!(wm_memory::content_hash("changed text"))
3068 );
3069 assert_eq!(v["prev_content_hash"], json!(original_hash));
3070
3071 let v = tool
3073 .call(
3074 &mut ctx,
3075 json!({"galaxy": "codex", "id": id.to_string(), "tags": ["amended"]}),
3076 )
3077 .await
3078 .unwrap();
3079 assert_eq!(
3080 v["content_hash"],
3081 json!(wm_memory::content_hash("changed text"))
3082 );
3083 assert!(v.get("prev_content_hash").is_none());
3084 }
3085
3086 #[tokio::test]
3087 async fn memory_update_appends_revision_chain() {
3088 let store = test_store();
3091 let mem = Memory::new(Galaxy::Codex, "original text".into());
3092 store.put(Galaxy::Codex, &mem).unwrap();
3093 let id = mem.metadata.id;
3094 let tool = MemoryUpdateTool::new(store.clone(), None);
3095 let mut ctx = Context {
3096 user_id: Some("agent-b".to_string()),
3097 session_id: Some(uuid::Uuid::nil()),
3098 compartment: Some("production".to_string()),
3099 ..Default::default()
3100 };
3101 let v = tool
3102 .call(
3103 &mut ctx,
3104 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3105 )
3106 .await
3107 .unwrap();
3108 assert_eq!(v["revision"]["seq"], 0);
3109 assert_eq!(
3110 v["revision"]["old_hash"],
3111 json!(wm_memory::content_hash("original text"))
3112 );
3113 assert_eq!(
3114 v["revision"]["new_hash"],
3115 json!(wm_memory::content_hash("second text"))
3116 );
3117
3118 let v = tool
3119 .call(
3120 &mut ctx,
3121 json!({"galaxy": "codex", "id": id.to_string(), "content": "third text"}),
3122 )
3123 .await
3124 .unwrap();
3125 assert_eq!(v["revision"]["seq"], 1);
3126
3127 let revisions = store.revisions(Galaxy::Codex, id).unwrap();
3128 assert_eq!(revisions.len(), 2);
3129 assert_eq!(revisions[1].old_hash, revisions[0].new_hash, "chain links");
3130 assert_eq!(revisions[0].actor_user.as_deref(), Some("agent-b"));
3131 assert_eq!(
3132 revisions[0].actor_compartment.as_deref(),
3133 Some("production")
3134 );
3135 assert_eq!(
3136 revisions[0].actor_session.as_deref(),
3137 Some(uuid::Uuid::nil().to_string().as_str())
3138 );
3139
3140 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3141 assert_eq!(stored.metadata.revision_count, 2);
3142
3143 let report = store
3145 .verify_revision_chain(Galaxy::Codex, id, &stored.metadata.content_hash)
3146 .unwrap();
3147 assert!(report.valid, "{:?}", report.breaks);
3148 assert!(report.matches_head);
3149 }
3150
3151 #[tokio::test]
3152 async fn memory_update_out_of_band_edit_breaks_chain() {
3153 let store = test_store();
3156 let mem = Memory::new(Galaxy::Codex, "original text".into());
3157 store.put(Galaxy::Codex, &mem).unwrap();
3158 let id = mem.metadata.id;
3159 let tool = MemoryUpdateTool::new(store.clone(), None);
3160 tool.call(
3161 &mut Context::default(),
3162 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3163 )
3164 .await
3165 .unwrap();
3166
3167 let mut row = store.get(Galaxy::Codex, id).unwrap().unwrap();
3169 row.content = "smuggled text".to_string();
3170 row.metadata.content_hash = wm_memory::content_hash("smuggled text");
3171 store.put(Galaxy::Codex, &row).unwrap();
3172
3173 let report = store
3174 .verify_revision_chain(Galaxy::Codex, id, &row.metadata.content_hash)
3175 .unwrap();
3176 assert!(!report.valid);
3177 assert!(!report.matches_head);
3178 assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
3179 }
3180
3181 #[tokio::test]
3182 async fn memory_revisions_tool_list_and_verify() {
3183 let store = test_store();
3184 let mem = Memory::new(Galaxy::Codex, "v1".into());
3185 store.put(Galaxy::Codex, &mem).unwrap();
3186 let id = mem.metadata.id;
3187 let update = MemoryUpdateTool::new(store.clone(), None);
3188 update
3189 .call(
3190 &mut Context::default(),
3191 json!({"galaxy": "codex", "id": id.to_string(), "content": "v2"}),
3192 )
3193 .await
3194 .unwrap();
3195
3196 let tool = MemoryRevisionsTool::new(store.clone());
3197 let v = tool
3198 .call(&mut Context::default(), json!({"id": id.to_string()}))
3199 .await
3200 .unwrap();
3201 assert_eq!(v["action"], "list");
3202 assert_eq!(v["count"], 1);
3203
3204 let v = tool
3205 .call(
3206 &mut Context::default(),
3207 json!({"id": id.to_string(), "action": "verify"}),
3208 )
3209 .await
3210 .unwrap();
3211 assert_eq!(v["valid"], true);
3212 assert_eq!(v["entries"], 1);
3213
3214 store
3217 .record_revision(
3218 Galaxy::Codex,
3219 id,
3220 "forged_old_hash",
3221 &wm_memory::content_hash("v2"),
3222 wm_memory::RevisionActor::default(),
3223 )
3224 .unwrap();
3225 let v = tool
3226 .call(
3227 &mut Context::default(),
3228 json!({"id": id.to_string(), "action": "verify"}),
3229 )
3230 .await
3231 .unwrap();
3232 assert_eq!(v["valid"], false);
3233 let breaks: Vec<String> = v["breaks"]
3234 .as_array()
3235 .unwrap()
3236 .iter()
3237 .map(|b| b.as_str().unwrap().to_string())
3238 .collect();
3239 assert!(
3240 breaks.iter().any(|b| b.contains("hash-linkage")),
3241 "{breaks:?}"
3242 );
3243 }
3244
3245 #[tokio::test]
3246 async fn memory_update_applies_importance_verbatim() {
3247 let store = test_store();
3253
3254 let tel = Memory::new(
3255 Galaxy::Codex,
3256 "## Auto-logged Friction: dispatch error\n\nbody".into(),
3257 );
3258 store.put(Galaxy::Codex, &tel).unwrap();
3259
3260 let tool = MemoryUpdateTool::new(store.clone(), None);
3261 let mut ctx = Context::default();
3262
3263 let v = tool
3264 .call(
3265 &mut ctx,
3266 json!({"galaxy": "codex", "id": tel.metadata.id.to_string(), "importance": 0.9}),
3267 )
3268 .await
3269 .unwrap();
3270 assert!(v.get("class_policy").is_none());
3271 assert!(v.get("write_gate").is_none());
3272 let stored = store.get(Galaxy::Codex, tel.metadata.id).unwrap().unwrap();
3273 assert!((stored.metadata.importance - 0.9).abs() < 1e-5);
3274 }
3275
3276 #[tokio::test]
3277 async fn memory_search_min_trust_filter_drops_low_trust() {
3278 let (_dir, store, search) = hybrid_fixture();
3282 let mut confirmed = Memory::new(Galaxy::Codex, "Quantum foal registry minutes".into());
3283 confirmed.metadata.source_trust = 1.0;
3284 confirmed.metadata.source = "user".to_string();
3285 let mut ingested = Memory::new(Galaxy::Codex, "Quantum foal registry draft".into());
3286 ingested.metadata.source_trust = 0.7;
3287 ingested.metadata.source = "tool".to_string();
3288 store.put(Galaxy::Codex, &confirmed).unwrap();
3289 store.put(Galaxy::Codex, &ingested).unwrap();
3290 mirror_memory(&store, &confirmed, None, 1);
3291 mirror_memory(&store, &ingested, None, 2);
3292
3293 let tool = default_search_tool(store, Some(search));
3294 let mut ctx = Context::default();
3295
3296 let v = tool
3297 .call(
3298 &mut ctx,
3299 json!({"query": "quantum foal registry", "limit": 10}),
3300 )
3301 .await
3302 .unwrap();
3303 assert_eq!(v["count"], 2, "no floor: both results surface");
3304 assert!(v.get("min_trust").is_none());
3305
3306 let v = tool
3307 .call(
3308 &mut ctx,
3309 json!({"query": "quantum foal registry", "limit": 10, "min_trust": 0.9}),
3310 )
3311 .await
3312 .unwrap();
3313 assert_eq!(v["min_trust"], 0.9);
3314 assert_eq!(v["min_trust_filtered"], 1);
3315 let trusts: Vec<f64> = v["results"]
3316 .as_array()
3317 .unwrap()
3318 .iter()
3319 .map(|r| r["trust"].as_f64().unwrap())
3320 .collect();
3321 assert!(trusts.iter().all(|t| *t >= 0.9), "{trusts:?}");
3322 }
3323
3324 #[tokio::test]
3325 async fn memory_sort_by_importance_asc() {
3326 let store = test_store();
3327 populate_memories(&store, Galaxy::Codex);
3328 let tool = MemorySortTool::new(store);
3329 let mut ctx = Context::default();
3330 let v = tool
3331 .call(&mut ctx, json!({"sort_by": "importance", "order": "asc"}))
3332 .await
3333 .unwrap();
3334 let mems = v["memories"].as_array().unwrap();
3335 assert!(mems[0]["importance"].as_f64().unwrap() <= mems[1]["importance"].as_f64().unwrap());
3336 }
3337
3338 #[tokio::test]
3339 async fn memory_sort_by_recency() {
3340 let store = test_store();
3341 populate_memories(&store, Galaxy::Codex);
3342 let tool = MemorySortTool::new(store);
3343 let mut ctx = Context::default();
3344 let v = tool
3345 .call(&mut ctx, json!({"sort_by": "recency"}))
3346 .await
3347 .unwrap();
3348 assert_eq!(v["returned"], 3);
3349 }
3350
3351 #[tokio::test]
3352 async fn memory_sort_invalid_field() {
3353 let store = test_store();
3354 let tool = MemorySortTool::new(store);
3355 let mut ctx = Context::default();
3356 let result = tool.call(&mut ctx, json!({"sort_by": "invalid"})).await;
3357 assert!(result.is_err());
3358 }
3359
3360 #[tokio::test]
3361 async fn memory_sort_with_limit() {
3362 let store = test_store();
3363 populate_memories(&store, Galaxy::Codex);
3364 let tool = MemorySortTool::new(store);
3365 let mut ctx = Context::default();
3366 let v = tool.call(&mut ctx, json!({"limit": 2})).await.unwrap();
3367 assert_eq!(v["returned"], 2);
3368 assert_eq!(v["total"], 3);
3369 }
3370
3371 #[tokio::test]
3372 async fn memory_filter_by_tag() {
3373 let store = test_store();
3374 populate_memories(&store, Galaxy::Codex);
3375 let tool = MemoryFilterTool::new(store);
3376 let mut ctx = Context::default();
3377 let v = tool
3378 .call(&mut ctx, json!({"tags": ["rust"]}))
3379 .await
3380 .unwrap();
3381 assert_eq!(v["matched"], 2);
3382 }
3383
3384 #[tokio::test]
3385 async fn memory_filter_by_importance_range() {
3386 let store = test_store();
3387 populate_memories(&store, Galaxy::Codex);
3388 let tool = MemoryFilterTool::new(store);
3389 let mut ctx = Context::default();
3390 let v = tool
3391 .call(
3392 &mut ctx,
3393 json!({"min_importance": 0.4, "max_importance": 0.6}),
3394 )
3395 .await
3396 .unwrap();
3397 assert_eq!(v["matched"], 1);
3398 }
3399
3400 #[tokio::test]
3404 async fn memory_filter_query_matches_content_and_title() {
3405 let store = test_store();
3406 let tool = MemoryFilterTool::new(store.clone());
3407 let mut ctx = Context::default();
3408
3409 let mut titled = Memory::new(Galaxy::Codex, "unrelated body text".into());
3410 titled.metadata.title = Some("Rust Borrow Checker".into());
3411 let plain = Memory::new(Galaxy::Codex, "rust ownership rules".into());
3412 let other = Memory::new(Galaxy::Codex, "gardening tips".into());
3413 for m in [&titled, &plain, &other] {
3414 store.put(Galaxy::Codex, m).unwrap();
3415 }
3416
3417 let v = tool.call(&mut ctx, json!({"query": "RUST"})).await.unwrap();
3418 assert_eq!(v["matched"], 2, "content hit + title hit: {v}");
3419 assert_eq!(v["filters"]["query_terms"], json!(["rust"]));
3420
3421 let v = tool
3422 .call(&mut ctx, json!({"query": "rust borrow"}))
3423 .await
3424 .unwrap();
3425 assert_eq!(v["matched"], 1, "all terms must match: {v}");
3426 assert_eq!(v["memories"][0]["content"], "unrelated body text");
3427 }
3428
3429 #[tokio::test]
3430 async fn memory_filter_no_matches() {
3431 let store = test_store();
3432 populate_memories(&store, Galaxy::Codex);
3433 let tool = MemoryFilterTool::new(store);
3434 let mut ctx = Context::default();
3435 let v = tool
3436 .call(&mut ctx, json!({"tags": ["nonexistent"]}))
3437 .await
3438 .unwrap();
3439 assert_eq!(v["matched"], 0);
3440 }
3441
3442 #[tokio::test]
3443 async fn memory_filter_combined_tags_and_importance() {
3444 let store = test_store();
3445 populate_memories(&store, Galaxy::Codex);
3446 let tool = MemoryFilterTool::new(store);
3447 let mut ctx = Context::default();
3448 let v = tool
3449 .call(&mut ctx, json!({"tags": ["rust"], "min_importance": 0.5}))
3450 .await
3451 .unwrap();
3452 assert_eq!(v["matched"], 1);
3453 }
3454
3455 #[tokio::test]
3459 async fn memory_filter_offset_exclude_tags_and_date_range() {
3460 let store = test_store();
3461 let tool = MemoryFilterTool::new(store.clone());
3462 let mut ctx = Context::default();
3463
3464 let mut recent_a = Memory::new(Galaxy::Codex, "recent a".into());
3465 recent_a.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(2);
3466 let mut recent_b = Memory::new(Galaxy::Codex, "recent b".into());
3467 recent_b.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
3468 recent_b.metadata.tags = vec!["noise".into()];
3469 let mut recent_priv = Memory::new(Galaxy::Codex, "recent private".into());
3470 recent_priv.metadata.created_at = chrono::Utc::now() - chrono::Duration::minutes(90);
3471 recent_priv.metadata.is_private = true;
3472 let mut old = Memory::new(Galaxy::Codex, "old relic".into());
3473 old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
3474 for m in [&recent_a, &recent_b, &recent_priv, &old] {
3475 store.put(Galaxy::Codex, m).unwrap();
3476 }
3477
3478 let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
3479 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
3480
3481 let v = tool
3483 .call(
3484 &mut ctx,
3485 json!({
3486 "galaxy": "codex",
3487 "created_after": cutoff,
3488 "exclude_tags": ["noise"],
3489 }),
3490 )
3491 .await
3492 .unwrap();
3493 assert_eq!(v["matched"], 1, "only recent-a is visible in range: {v}");
3494 assert_eq!(v["returned"], 1);
3495 assert_eq!(v["memories"][0]["content"], "recent a");
3496 assert_eq!(v["filters"]["exclude_tags"], json!(["noise"]));
3497 assert!(v["filters"]["created_after"].is_string());
3498
3499 let page2 = tool
3502 .call(
3503 &mut ctx,
3504 json!({"galaxy": "codex", "offset": 3, "limit": 2}),
3505 )
3506 .await
3507 .unwrap();
3508 assert_eq!(
3509 page2["matched"], 3,
3510 "private memory must not count: {page2}"
3511 );
3512 assert_eq!(
3513 page2["returned"], 0,
3514 "offset past the match set is an honest empty page"
3515 );
3516 assert_eq!(page2["offset"], 3);
3517
3518 let bad = tool
3520 .call(
3521 &mut ctx,
3522 json!({"galaxy": "codex", "created_before": "yesterday"}),
3523 )
3524 .await;
3525 assert!(bad.is_err(), "non-RFC-3339 bound must be refused");
3526 }
3527
3528 #[tokio::test]
3529 async fn memory_deduplicate_hash_dry_run() {
3530 let store = test_store();
3531 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3532 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3533 let _ = store.put(Galaxy::Codex, &m1);
3534 let _ = store.put(Galaxy::Codex, &m2);
3535 let _ = store.put(
3536 Galaxy::Codex,
3537 &Memory::new(Galaxy::Codex, "unique content".into()),
3538 );
3539
3540 let tool = MemoryDeduplicateTool::new(store.clone(), None);
3541 let mut ctx = Context::default();
3542 let v = tool
3543 .call(&mut ctx, json!({"mode": "hash", "dry_run": true}))
3544 .await
3545 .unwrap();
3546 assert_eq!(v["duplicates_found"], 1);
3547 assert_eq!(v["removed"], 0);
3548
3549 let memories = store.scan(Galaxy::Codex, 100).unwrap();
3550 assert_eq!(memories.len(), 3);
3551 }
3552
3553 #[tokio::test]
3554 async fn memory_deduplicate_hash_execute() {
3555 let store = test_store();
3556 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3557 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3558 let _ = store.put(Galaxy::Codex, &m1);
3559 let _ = store.put(Galaxy::Codex, &m2);
3560 let _ = store.put(
3561 Galaxy::Codex,
3562 &Memory::new(Galaxy::Codex, "unique content".into()),
3563 );
3564
3565 let tool = MemoryDeduplicateTool::new(store.clone(), None);
3566 let mut ctx = Context::default();
3567 let v = tool
3568 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3569 .await
3570 .unwrap();
3571 assert_eq!(v["duplicates_found"], 1);
3572 assert_eq!(v["removed"], 1);
3573
3574 let memories = store.scan(Galaxy::Codex, 100).unwrap();
3575 assert_eq!(memories.len(), 2);
3576 }
3577
3578 #[tokio::test]
3579 async fn memory_deduplicate_deindexes_removed_memories() {
3580 let (_dir, store, search) = hybrid_fixture();
3583
3584 let m1 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3585 let m2 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3586 let id1 = m1.metadata.id;
3587 let id2 = m2.metadata.id;
3588 let _ = store.put(Galaxy::Codex, &m1);
3589 let _ = store.put(Galaxy::Codex, &m2);
3590 for mem in [&m1, &m2] {
3591 let mut writer = search.writer().unwrap();
3592 search
3593 .add_document(
3594 &mut writer,
3595 &mem.metadata.id.to_string(),
3596 mem.metadata.galaxy.db_name(),
3597 &mem.content,
3598 &mem.metadata.tags,
3599 mem.metadata.created_at.timestamp(),
3600 )
3601 .unwrap();
3602 search.commit(&mut writer).unwrap();
3603 }
3604
3605 let before = search.search_ids("index drift", 100).unwrap();
3607 assert_eq!(before.len(), 2);
3608
3609 let tool = MemoryDeduplicateTool::new(store.clone(), Some(search.clone()));
3610 let mut ctx = Context::default();
3611 let v = tool
3612 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3613 .await
3614 .unwrap();
3615 assert_eq!(v["removed"], 1);
3616
3617 let after = search.search_ids("index drift", 100).unwrap();
3620 assert_eq!(after.len(), 1, "search index should only contain survivors");
3621 assert!(
3622 after.contains(&id1) || after.contains(&id2),
3623 "survivor should be one of the original memories"
3624 );
3625 }
3626
3627 #[tokio::test]
3628 async fn memory_deduplicate_content_mode() {
3629 let store = test_store();
3630 let m1 = Memory::new(Galaxy::Codex, "same text".into());
3631 let m2 = Memory::new(Galaxy::Codex, "same text".into());
3632 let _ = store.put(Galaxy::Codex, &m1);
3633 let _ = store.put(Galaxy::Codex, &m2);
3634
3635 let tool = MemoryDeduplicateTool::new(store, None);
3636 let mut ctx = Context::default();
3637 let v = tool
3638 .call(&mut ctx, json!({"mode": "content", "dry_run": true}))
3639 .await
3640 .unwrap();
3641 assert_eq!(v["duplicates_found"], 1);
3642 }
3643
3644 #[tokio::test]
3645 async fn memory_deduplicate_no_duplicates() {
3646 let store = test_store();
3647 let _ = store.put(
3648 Galaxy::Codex,
3649 &Memory::new(Galaxy::Codex, "content a".into()),
3650 );
3651 let _ = store.put(
3652 Galaxy::Codex,
3653 &Memory::new(Galaxy::Codex, "content b".into()),
3654 );
3655
3656 let tool = MemoryDeduplicateTool::new(store, None);
3657 let mut ctx = Context::default();
3658 let v = tool.call(&mut ctx, json!({})).await.unwrap();
3659 assert_eq!(v["duplicates_found"], 0);
3660 }
3661
3662 #[tokio::test]
3663 async fn memory_deduplicate_invalid_mode() {
3664 let store = test_store();
3665 let tool = MemoryDeduplicateTool::new(store, None);
3666 let mut ctx = Context::default();
3667 let result = tool.call(&mut ctx, json!({"mode": "invalid"})).await;
3668 assert!(result.is_err());
3669 }
3670
3671 #[tokio::test]
3672 async fn memory_export_json() {
3673 let store = test_store();
3674 populate_memories(&store, Galaxy::Codex);
3675 let tool = MemoryExportTool::new(store);
3676 let mut ctx = Context::default();
3677 let v = tool
3678 .call(&mut ctx, json!({"format": "json"}))
3679 .await
3680 .unwrap();
3681 assert_eq!(v["format"], "json");
3682 assert_eq!(v["count"], 3);
3683 assert!(v["export"].as_str().unwrap().contains("First memory"));
3684 }
3685
3686 #[tokio::test]
3687 async fn memory_export_csv() {
3688 let store = test_store();
3689 populate_memories(&store, Galaxy::Codex);
3690 let tool = MemoryExportTool::new(store);
3691 let mut ctx = Context::default();
3692 let v = tool.call(&mut ctx, json!({"format": "csv"})).await.unwrap();
3693 let csv = v["export"].as_str().unwrap();
3694 assert!(csv.contains("id,content,tags"));
3695 assert!(csv.contains("First memory"));
3696 }
3697
3698 #[tokio::test]
3699 async fn memory_export_markdown() {
3700 let store = test_store();
3701 populate_memories(&store, Galaxy::Codex);
3702 let tool = MemoryExportTool::new(store);
3703 let mut ctx = Context::default();
3704 let v = tool
3705 .call(&mut ctx, json!({"format": "markdown"}))
3706 .await
3707 .unwrap();
3708 let md = v["export"].as_str().unwrap();
3709 assert!(md.contains("# Memory Export"));
3710 assert!(md.contains("First memory"));
3711 }
3712
3713 #[tokio::test]
3714 async fn memory_export_invalid_format() {
3715 let store = test_store();
3716 let tool = MemoryExportTool::new(store);
3717 let mut ctx = Context::default();
3718 let result = tool.call(&mut ctx, json!({"format": "xml"})).await;
3719 assert!(result.is_err());
3720 }
3721
3722 #[tokio::test]
3723 async fn memory_export_empty_galaxy() {
3724 let store = test_store();
3725 let tool = MemoryExportTool::new(store);
3726 let mut ctx = Context::default();
3727 let v = tool
3728 .call(&mut ctx, json!({"format": "json"}))
3729 .await
3730 .unwrap();
3731 assert_eq!(v["count"], 0);
3732 }
3733
3734 #[tokio::test]
3735 async fn memory_sort_and_filter_are_winnowing_basket_gana() {
3736 let store = test_store();
3737 assert_eq!(
3738 MemorySortTool::new(store.clone()).gana(),
3739 Gana::WinnowingBasket
3740 );
3741 assert_eq!(
3742 MemoryFilterTool::new(store.clone()).gana(),
3743 Gana::WinnowingBasket
3744 );
3745 assert_eq!(
3746 MemoryDeduplicateTool::new(store.clone(), None).gana(),
3747 Gana::WinnowingBasket
3748 );
3749 assert_eq!(MemoryExportTool::new(store).gana(), Gana::WinnowingBasket);
3750 }
3751
3752 #[test]
3761 fn hybrid_recall_routes_expose_query_schema() {
3762 let dir = tempfile::tempdir().unwrap();
3763 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3764 for tool in [
3765 MemoryHybridRecallTool::new(store.clone(), None, None),
3766 MemoryHybridRecallTool::as_search(store, None, None),
3767 ] {
3768 let schema = tool.input_schema();
3769 assert_eq!(schema["type"], "object");
3770 assert!(schema["properties"].get("query").is_some());
3771 assert_eq!(schema["required"], json!(["query"]));
3772 }
3773 }
3774
3775 fn hybrid_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
3778 let dir = tempfile::tempdir().unwrap();
3779 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3780 let tantivy_dir = dir.path().join("tantivy");
3781 std::fs::create_dir_all(&tantivy_dir).unwrap();
3782 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3783 (dir, store, search)
3784 }
3785
3786 fn index_memory(
3787 store: &Arc<MemoryStore>,
3788 search: &Arc<SearchEngine>,
3789 galaxy: Galaxy,
3790 content: &str,
3791 ) {
3792 let mem = Memory::new(galaxy, content.to_string());
3793 let id = mem.metadata.id;
3794 store.put(galaxy, &mem).unwrap();
3795 let mut writer = search.writer().unwrap();
3796 search
3797 .add_document(
3798 &mut writer,
3799 &id.to_string(),
3800 galaxy.db_name(),
3801 content,
3802 &mem.metadata.tags,
3803 mem.metadata.created_at.timestamp(),
3804 )
3805 .unwrap();
3806 search.commit(&mut writer).unwrap();
3807 }
3808
3809 #[tokio::test]
3810 async fn hybrid_recall_excludes_private_memories() {
3811 let (_dir, store, search) = hybrid_fixture();
3812
3813 let mut priv_mem = Memory::new(Galaxy::Codex, "private secret plan alpha".to_string());
3815 priv_mem.metadata.is_private = true;
3816 let id = priv_mem.metadata.id;
3817 store.put(Galaxy::Codex, &priv_mem).unwrap();
3818 {
3819 let mut writer = search.writer().unwrap();
3820 search
3821 .add_document(
3822 &mut writer,
3823 &id.to_string(),
3824 "codex",
3825 "private secret plan alpha",
3826 &[],
3827 priv_mem.metadata.created_at.timestamp(),
3828 )
3829 .unwrap();
3830 search.commit(&mut writer).unwrap();
3831 }
3832
3833 index_memory(
3835 &store,
3836 &search,
3837 Galaxy::Codex,
3838 "public plan alpha documentation",
3839 );
3840
3841 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
3842 let v = tool
3843 .call(
3844 &mut Context::default(),
3845 json!({"query": "plan alpha", "galaxy": "codex"}),
3846 )
3847 .await
3848 .unwrap();
3849 let results = v["results"].as_array().unwrap();
3850 let contents: Vec<&str> = results
3851 .iter()
3852 .filter_map(|r| r["content"].as_str())
3853 .collect();
3854 assert!(
3855 !contents.iter().any(|c| c.contains("private")),
3856 "private memory leaked through hybrid recall: {results:?}"
3857 );
3858 assert!(
3859 contents.iter().any(|c| c.contains("public")),
3860 "public memory missing from hybrid recall: {results:?}"
3861 );
3862 }
3863
3864 #[tokio::test]
3865 async fn batch_read_treats_private_as_miss() {
3866 let store = test_store();
3867 let mut priv_mem = Memory::new(Galaxy::Codex, "private batch note".into());
3868 priv_mem.metadata.is_private = true;
3869 let priv_id = priv_mem.metadata.id;
3870 store.put(Galaxy::Codex, &priv_mem).unwrap();
3871 let pub_mem = Memory::new(Galaxy::Codex, "public batch note".into());
3872 let pub_id = pub_mem.metadata.id;
3873 store.put(Galaxy::Codex, &pub_mem).unwrap();
3874
3875 let tool = MemoryBatchReadTool::new(store);
3876 let v = tool
3877 .call(
3878 &mut Context::default(),
3879 json!({"galaxy": "codex", "ids": [priv_id.to_string(), pub_id.to_string()]}),
3880 )
3881 .await
3882 .unwrap();
3883 assert_eq!(v["found"], 1);
3884 assert_eq!(v["misses"], 1);
3885 assert!(
3886 !v["memories"]
3887 .as_array()
3888 .unwrap()
3889 .iter()
3890 .any(|m| m["content"].as_str().unwrap_or("").contains("private")),
3891 "private memory leaked through batch_read: {v}"
3892 );
3893 }
3894
3895 #[tokio::test]
3896 async fn hybrid_recall_incident_query_returns_only_relevant() {
3897 let (_dir, store, search) = hybrid_fixture();
3898 index_memory(
3899 &store,
3900 &search,
3901 Galaxy::Codex,
3902 "smoke test from wmClient: verify recall",
3903 );
3904 index_memory(
3905 &store,
3906 &search,
3907 Galaxy::Codex,
3908 "NES Evolution and Impact: a history of the console wars",
3909 );
3910 index_memory(
3911 &store,
3912 &search,
3913 Galaxy::Codex,
3914 "Insights on The Gateless Gate: koans and zen practice",
3915 );
3916 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3917 let mut ctx = Context::default();
3918 let v = tool
3919 .call(
3920 &mut ctx,
3921 json!({"query": "smoke test", "galaxy": "codex", "limit": 5}),
3922 )
3923 .await
3924 .unwrap();
3925 let results = v["results"].as_array().unwrap();
3926 assert_eq!(
3927 results.len(),
3928 1,
3929 "incident query must not return unrelated memories: {results:?}"
3930 );
3931 let hit = &results[0];
3932 assert_eq!(hit["source"], "fts");
3933 assert!(
3934 hit["content"]
3935 .as_str()
3936 .unwrap()
3937 .contains("smoke test from wmClient")
3938 );
3939 assert!(hit["normalized_score"].as_f64().unwrap() > 0.0);
3940 assert_eq!(v["count"], 1);
3941 }
3942
3943 #[tokio::test]
3944 async fn hybrid_recall_filters_stale_index_entries() {
3945 let (_dir, store, search) = hybrid_fixture();
3948 index_memory(
3949 &store,
3950 &search,
3951 Galaxy::Codex,
3952 "rust memory about ownership",
3953 );
3954 {
3955 let mut writer = search.writer().unwrap();
3956 search
3957 .add_document(
3958 &mut writer,
3959 "99999999-9999-9999-9999-999999999999",
3960 "codex",
3961 "rust ghost memory",
3962 &[],
3963 1700000000,
3964 )
3965 .unwrap();
3966 search.commit(&mut writer).unwrap();
3967 }
3968
3969 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3970 let mut ctx = Context::default();
3971 let v = tool
3972 .call(&mut ctx, json!({"query": "rust", "limit": 10}))
3973 .await
3974 .unwrap();
3975 let results = v["results"].as_array().unwrap();
3976 assert_eq!(results.len(), 1);
3977 assert_ne!(
3978 results[0]["id"].as_str().unwrap(),
3979 "99999999-9999-9999-9999-999999999999"
3980 );
3981 }
3982
3983 #[tokio::test]
3984 async fn hybrid_recall_respects_min_score_arg() {
3985 let (_dir, store, search) = hybrid_fixture();
3986 index_memory(&store, &search, Galaxy::Codex, "alpha");
3987 let filler = format!("alpha {}", "zzz ".repeat(400));
3988 index_memory(&store, &search, Galaxy::Codex, &filler);
3989
3990 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
3992 let mut ctx = Context::default();
3993 let v = tool
3994 .call(&mut ctx, json!({"query": "alpha", "limit": 10}))
3995 .await
3996 .unwrap();
3997 assert_eq!(v["count"], 2);
3998
3999 let scores: Vec<f64> = v["results"]
4001 .as_array()
4002 .unwrap()
4003 .iter()
4004 .map(|r| r["score"].as_f64().unwrap())
4005 .collect();
4006 let lo = scores.iter().copied().fold(f64::MAX, f64::min);
4007 let hi = scores.iter().copied().fold(0.0, f64::max);
4008 let mid = f64::midpoint(hi, lo);
4009
4010 let v = tool
4011 .call(
4012 &mut ctx,
4013 json!({"query": "alpha", "limit": 10, "min_score": mid}),
4014 )
4015 .await
4016 .unwrap();
4017 assert_eq!(v["count"], 1);
4018 assert!((v["results"][0]["score"].as_f64().unwrap() - hi).abs() < 1e-3);
4019 }
4020
4021 #[tokio::test]
4022 async fn hybrid_recall_or_coverage_finds_partial_matches() {
4023 let (_dir, store, search) = hybrid_fixture();
4027 index_memory(&store, &search, Galaxy::Codex, "alpha only here");
4028 index_memory(&store, &search, Galaxy::Codex, "alpha beta gamma delta");
4029
4030 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4031 let mut ctx = Context::default();
4032 let v = tool
4033 .call(&mut ctx, json!({"query": "alpha beta gamma", "limit": 10}))
4034 .await
4035 .unwrap();
4036 let results = v["results"].as_array().unwrap();
4037 assert_eq!(results.len(), 1);
4040 assert!(
4041 results[0]["content"]
4042 .as_str()
4043 .unwrap()
4044 .contains("alpha beta gamma")
4045 );
4046
4047 let v = tool
4050 .call(
4051 &mut ctx,
4052 json!({"query": "alpha beta gamma zeta", "limit": 10}),
4053 )
4054 .await
4055 .unwrap();
4056 let results = v["results"].as_array().unwrap();
4057 assert_eq!(
4058 results.len(),
4059 1,
4060 "OR + coverage must require 2/4 token coverage: {results:?}"
4061 );
4062 assert!(
4063 results[0]["content"]
4064 .as_str()
4065 .unwrap()
4066 .contains("alpha beta gamma")
4067 );
4068 for r in results {
4069 assert!(
4070 matches!(r["source"].as_str(), Some("fts")),
4071 "results should be tagged fts"
4072 );
4073 }
4074 }
4075
4076 fn index_tagged_memory(
4077 store: &Arc<MemoryStore>,
4078 search: &Arc<SearchEngine>,
4079 galaxy: Galaxy,
4080 content: &str,
4081 tags: &[&str],
4082 ) {
4083 let mut mem = Memory::new(galaxy, content.to_string());
4084 mem.metadata.tags = tags.iter().map(ToString::to_string).collect();
4085 let id = mem.metadata.id;
4086 store.put(galaxy, &mem).unwrap();
4087 let mut writer = search.writer().unwrap();
4088 search
4089 .add_document(
4090 &mut writer,
4091 &id.to_string(),
4092 galaxy.db_name(),
4093 content,
4094 &mem.metadata.tags,
4095 mem.metadata.created_at.timestamp(),
4096 )
4097 .unwrap();
4098 search.commit(&mut writer).unwrap();
4099 }
4100
4101 fn aggregate_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4102 let (dir, store, search) = hybrid_fixture();
4103 index_tagged_memory(
4105 &store,
4106 &search,
4107 Galaxy::Codex,
4108 "I started learning Rust.",
4109 &["user", "session_002"],
4110 );
4111 index_tagged_memory(
4112 &store,
4113 &search,
4114 Galaxy::Codex,
4115 "I finished my first Rust project, a CLI tool.",
4116 &["user", "session_007"],
4117 );
4118 index_tagged_memory(
4119 &store,
4120 &search,
4121 Galaxy::Codex,
4122 "I got a job as a systems engineer using Rust.",
4123 &["user", "session_012"],
4124 );
4125 index_tagged_memory(
4128 &store,
4129 &search,
4130 Galaxy::Codex,
4131 "I started learning Go.",
4132 &["user", "session_003"],
4133 );
4134 index_tagged_memory(
4135 &store,
4136 &search,
4137 Galaxy::Codex,
4138 "I got a job as a backend engineer using Go.",
4139 &["user", "session_015"],
4140 );
4141 (dir, store, search)
4142 }
4143
4144 #[tokio::test]
4145 async fn aggregate_session_span_isolated_by_rarest_term() {
4146 let (_dir, store, search) = aggregate_fixture();
4147 let tool = MemoryAggregateTool::new(Some(search), store);
4148 let mut ctx = Context::default();
4149 let v = tool
4150 .call(
4151 &mut ctx,
4152 json!({
4153 "query": "How long did it take from starting Rust to getting a job using it?",
4154 "metric": "session_span",
4155 }),
4156 )
4157 .await
4158 .unwrap();
4159 assert_eq!(v["aggregate"]["value"], 10, "session_012 - session_002");
4160 assert_eq!(v["aggregate"]["unit"], "sessions");
4161 assert_eq!(v["aggregate"]["content"], "10 sessions");
4162 }
4163
4164 #[tokio::test]
4165 async fn aggregate_session_count() {
4166 let (_dir, store, search) = aggregate_fixture();
4167 let tool = MemoryAggregateTool::new(Some(search), store);
4168 let mut ctx = Context::default();
4169 let v = tool
4170 .call(
4171 &mut ctx,
4172 json!({
4173 "query": "How long did it take from starting Rust to getting a job using it?",
4174 "metric": "session_count",
4175 }),
4176 )
4177 .await
4178 .unwrap();
4179 assert_eq!(v["aggregate"]["value"], 2);
4185 }
4186
4187 #[tokio::test]
4188 async fn aggregate_count_needs_no_session_tags() {
4189 let (_dir, store, search) = aggregate_fixture();
4190 let tool = MemoryAggregateTool::new(Some(search), store);
4191 let mut ctx = Context::default();
4192 let v = tool
4193 .call(
4194 &mut ctx,
4195 json!({"query": "Rust project", "metric": "count"}),
4196 )
4197 .await
4198 .unwrap();
4199 assert_eq!(v["aggregate"]["value"], 3);
4201 }
4202
4203 #[tokio::test]
4207 async fn aggregate_single_session_falls_back_honestly() {
4208 let (_dir, store, search) = aggregate_fixture();
4209 let tool = MemoryAggregateTool::new(Some(search), store);
4210 let mut ctx = Context::default();
4211 let v = tool
4213 .call(
4214 &mut ctx,
4215 json!({"query": "CLI tool", "metric": "session_count"}),
4216 )
4217 .await
4218 .unwrap();
4219 assert_eq!(v["aggregate"]["value"], 1, "one session in evidence: {v}");
4220 assert_eq!(v["anchor"], "session_tagged_fallback");
4221 let v = tool
4222 .call(
4223 &mut ctx,
4224 json!({"query": "CLI tool", "metric": "session_span"}),
4225 )
4226 .await
4227 .unwrap();
4228 assert_eq!(v["aggregate"]["value"], 0, "single point spans 0: {v}");
4229 }
4230
4231 #[tokio::test]
4232 async fn aggregate_rejects_unknown_metric() {
4233 let (_dir, store, search) = aggregate_fixture();
4234 let tool = MemoryAggregateTool::new(Some(search), store);
4235 let mut ctx = Context::default();
4236 let err = tool
4237 .call(&mut ctx, json!({"query": "x", "metric": "median"}))
4238 .await
4239 .unwrap_err();
4240 assert!(err.to_string().contains("unknown metric"));
4241 }
4242}