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