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