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 cold-storage discovery: scan, hydrate and integrity-verify cold originals by content (bounded, no thaw)"),
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 let outcome = self.store.find_cold_matching(
1446 &terms,
1447 if galaxy_explicit { Some(galaxy) } else { None },
1448 remaining.max(1),
1449 cold_scan_limit,
1450 )?;
1451 let existing: std::collections::HashSet<String> = results
1452 .iter()
1453 .filter_map(|r| {
1454 r.get("id")
1455 .and_then(serde_json::Value::as_str)
1456 .map(str::to_string)
1457 })
1458 .collect();
1459 let mut appended = 0usize;
1460 for record in &outcome.records {
1461 if appended >= remaining {
1462 break;
1463 }
1464 let id = record.id.to_string();
1465 if existing.contains(&id) {
1466 continue;
1467 }
1468 let mem = record.decompress()?;
1469 results.push(json!({
1470 "id": id,
1471 "content": &mem.content,
1472 "importance": mem.metadata.importance,
1473 "score": serde_json::Value::Null,
1474 "source": "cold",
1475 "cold": true,
1476 "integrity": "verified",
1477 "model_visible": !mem.metadata.model_exclude,
1478 "tags": &mem.metadata.tags,
1479 }));
1480 appended += 1;
1481 }
1482 if appended > 0 && recall_mode == "none" {
1483 recall_mode = "cold";
1484 }
1485 Some(json!({
1486 "enabled": true,
1487 "scanned": outcome.scanned,
1488 "candidates": outcome.candidates,
1489 "matched": outcome.matched,
1490 "appended": appended,
1491 "integrity_rejected": outcome.integrity_rejected,
1492 "private_skipped": outcome.private_skipped,
1493 "non_current_skipped": outcome.non_current_skipped,
1494 "no_thaw": true,
1495 }))
1496 } else {
1497 None
1498 };
1499 let hint = if results.is_empty() && !query.is_empty() {
1500 Some(if galaxy_explicit {
1501 empty_result_hint(&self.store, galaxy)
1502 } else {
1503 empty_result_hint_all(&self.store)
1504 })
1505 } else {
1506 None
1507 };
1508 let mut out = json!({
1509 "status": "success",
1510 "galaxy": if galaxy_explicit {
1511 serde_json::Value::from(galaxy_name(galaxy))
1512 } else {
1513 serde_json::Value::from("all")
1514 },
1515 "count": results.len(),
1516 "recall_mode": recall_mode,
1517 "results": results,
1518 "hint": hint,
1519 });
1520 if let Some(extra) = result_extra {
1523 out["conformal_set"] = extra;
1524 }
1525 if let Some(td) = trust_disclosure {
1526 out["trust_weighting"] = td;
1527 }
1528 if let Some(reason) = hybrid_degraded {
1532 out["hybrid_degraded"] = json!(reason);
1533 }
1534 if let Some(min) = min_trust {
1535 out["min_trust"] = json!(min);
1536 out["min_trust_filtered"] = json!(min_trust_filtered);
1537 }
1538 if let Some(cd) = cold_discovery {
1539 out["cold_discovery"] = cd;
1540 }
1541 Ok(out)
1542 }
1543 fn stats(&self) -> &ToolStats {
1544 &self.stats
1545 }
1546}
1547
1548pub struct MemoryRecallFeedbackTool {
1558 recall: Option<Arc<RecallEngine>>,
1559 stats: ToolStats,
1560 effects: EffectRow,
1561}
1562
1563impl MemoryRecallFeedbackTool {
1564 #[must_use]
1565 pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1566 Self {
1567 recall,
1568 stats: ToolStats::default(),
1569 effects: EffectRow {
1574 writes: vec![Resource::Filesystem],
1575 ..Default::default()
1576 },
1577 }
1578 }
1579}
1580
1581#[async_trait]
1582impl Tool for MemoryRecallFeedbackTool {
1583 fn name(&self) -> &str {
1584 "memory.recall_feedback"
1585 }
1586 fn gana(&self) -> Gana {
1587 Gana::WinnowingBasket
1588 }
1589 fn effects(&self) -> &EffectRow {
1590 &self.effects
1591 }
1592 fn description(&self) -> &str {
1593 "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."
1594 }
1595 fn input_schema(&self) -> Value {
1596 schema(
1597 &json!({
1598 "samples": {"type": "array", "description": "Feedback samples: [{score: 0-1 fused score, relevant: bool}]"},
1599 "score": num_prop("Single-sample fused score (0-1)"),
1600 "relevant": {"type": "boolean", "description": "Single-sample relevance label"},
1601 }),
1602 &[],
1603 )
1604 }
1605 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1606 let Some(ref recall) = self.recall else {
1607 return Ok(json!({
1608 "status": "error",
1609 "message": "no recall engine on this server (hybrid search unavailable) — nothing to calibrate",
1610 }));
1611 };
1612 let mut samples: Vec<(f32, bool)> = Vec::new();
1613 if let Some(list) = args.get("samples").and_then(Value::as_array) {
1614 for s in list {
1615 let score = s.get("score").and_then(Value::as_f64).unwrap_or(-1.0);
1616 let relevant = s.get("relevant").and_then(Value::as_bool);
1617 if !(0.0..=1.0).contains(&score) || relevant.is_none() {
1618 return Err(wm_core::CoreError::InvalidArgs(
1619 "each sample needs score in [0,1] and a boolean 'relevant'".into(),
1620 ));
1621 }
1622 samples.push((score as f32, relevant.unwrap_or(false)));
1623 }
1624 } else if let Some(score) = args.get("score").and_then(Value::as_f64) {
1625 let relevant = args
1626 .get("relevant")
1627 .and_then(Value::as_bool)
1628 .ok_or_else(|| {
1629 wm_core::CoreError::InvalidArgs("'relevant' is required with 'score'".into())
1630 })?;
1631 if !(0.0..=1.0).contains(&score) {
1632 return Err(wm_core::CoreError::InvalidArgs(
1633 "'score' must be within [0,1]".into(),
1634 ));
1635 }
1636 samples.push((score as f32, relevant));
1637 } else {
1638 return Err(wm_core::CoreError::InvalidArgs(
1639 "provide 'samples' (array of {score, relevant}) or a single 'score' + 'relevant'"
1640 .into(),
1641 ));
1642 }
1643
1644 let mut recorded = 0usize;
1645 let mut count = 0usize;
1646 for (score, relevant) in samples {
1647 count = recall.record_relevance_feedback(score, relevant)?;
1648 recorded += 1;
1649 }
1650 let status = recall
1653 .conformal_disclosure(&mut Vec::new())?
1654 .map_or_else(|| "off".into(), |info| info.status);
1655 Ok(json!({
1656 "status": "success",
1657 "recorded": recorded,
1658 "calibration_samples": count,
1659 "conformal_status": status,
1660 }))
1661 }
1662 fn stats(&self) -> &ToolStats {
1663 &self.stats
1664 }
1665}
1666
1667pub struct MemoryEpisodicSearchTool {
1671 store: Arc<MemoryStore>,
1672 stats: ToolStats,
1673 effects: EffectRow,
1674}
1675
1676impl MemoryEpisodicSearchTool {
1677 pub fn new(store: Arc<MemoryStore>) -> Self {
1678 Self {
1679 store,
1680 stats: ToolStats::default(),
1681 effects: EffectRow::read_only(vec![Resource::Galaxy("episodic_records".into())]),
1682 }
1683 }
1684}
1685
1686#[async_trait]
1687impl Tool for MemoryEpisodicSearchTool {
1688 fn name(&self) -> &str {
1689 "memory.episodic_search"
1690 }
1691 fn gana(&self) -> Gana {
1692 Gana::WinnowingBasket
1693 }
1694 fn effects(&self) -> &EffectRow {
1695 &self.effects
1696 }
1697 fn description(&self) -> &str {
1698 "[V6 Experimental] Search explicit episodic records with provenance and lifecycle filtering"
1699 }
1700 fn input_schema(&self) -> Value {
1701 schema(
1702 &json!({
1703 "query": str_prop("Full-text query"),
1704 "limit": int_prop("Maximum results (default 10)"),
1705 "candidate_limit": int_prop("Maximum candidates to score (default 2x limit)"),
1706 "include_historical": {
1707 "type": "boolean",
1708 "description": "Include superseded, revoked, and archived records",
1709 },
1710 "rerank": {
1711 "type": "boolean",
1712 "description": "Enable vector reranking (requires embedder, default false)",
1713 },
1714 "rerank_alpha": {
1715 "type": "number",
1716 "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)",
1717 },
1718 "min_score": {
1719 "type": "number",
1720 "description": "Minimum score threshold; results below this are dropped (abstention). Default 0.0 (no threshold)",
1721 },
1722 "min_coverage": {
1723 "type": "number",
1724 "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)",
1725 },
1726 }),
1727 &["query"],
1728 )
1729 }
1730 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1731 let query = args.get("query").and_then(Value::as_str).unwrap_or("");
1732 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
1733 let candidate_limit =
1734 args.get("candidate_limit")
1735 .and_then(Value::as_u64)
1736 .unwrap_or_else(|| limit.saturating_mul(2) as u64) as usize;
1737 let include_historical = args
1738 .get("include_historical")
1739 .and_then(Value::as_bool)
1740 .unwrap_or(false);
1741 let rerank = args.get("rerank").and_then(Value::as_bool).unwrap_or(false);
1742 let rerank_alpha = args
1743 .get("rerank_alpha")
1744 .and_then(Value::as_f64)
1745 .unwrap_or(0.7) as f32;
1746 let min_score = args
1747 .get("min_score")
1748 .and_then(Value::as_f64)
1749 .map(|v| v as f32);
1750 let min_coverage = args
1751 .get("min_coverage")
1752 .and_then(Value::as_f64)
1753 .map(|v| v as f32);
1754 let query_term_count: usize = {
1758 const STOPWORDS: &[&str] = &[
1759 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
1760 "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
1761 "must", "can", "shall", "to", "of", "in", "on", "at", "by", "for", "with", "about",
1762 "as", "into", "like", "through", "after", "over", "between", "out", "against",
1763 "during", "without", "before", "under", "around", "among", "i", "me", "my", "we",
1764 "us", "our", "you", "your", "he", "him", "his", "she", "her", "it", "its", "they",
1765 "them", "their", "what", "whats", "who", "when", "where", "why", "how", "and",
1766 "or", "but", "not", "no", "nor", "so", "yet", "both", "either", "neither", "this",
1767 "that", "these", "those", "there", "here", "now", "then", "than",
1768 ];
1769 query
1770 .split(|c: char| !c.is_alphanumeric())
1771 .filter(|t| t.len() > 1)
1772 .map(str::to_ascii_lowercase)
1773 .filter(|t| !STOPWORDS.contains(&t.as_str()))
1774 .collect::<std::collections::HashSet<_>>()
1775 .len()
1776 };
1777 let raw_results = if rerank {
1778 self.store.episodic().search_with_rerank(
1779 query,
1780 limit,
1781 candidate_limit,
1782 include_historical,
1783 rerank_alpha,
1784 )?
1785 } else {
1786 self.store.episodic().search_with_limits(
1787 query,
1788 limit,
1789 candidate_limit,
1790 include_historical,
1791 )?
1792 };
1793 let is_count_query = query.to_ascii_lowercase().contains("how many");
1801 let abstain = min_coverage.is_some()
1802 && !is_count_query
1803 && query_term_count >= 3
1804 && !raw_results.iter().any(|hit| hit.matched_terms >= 2);
1805 let visible: Vec<_> = raw_results
1806 .into_iter()
1807 .filter(|hit| !hit.record.is_private && !hit.record.model_exclude)
1808 .filter(|hit| min_score.is_none_or(|ms| hit.score >= ms))
1809 .filter(|_| !abstain)
1810 .take(limit)
1811 .collect();
1812 let conflicts = detect_conflicts(&visible);
1816 let results = visible
1817 .into_iter()
1818 .map(|hit| {
1819 json!({
1820 "id": hit.record.id,
1821 "content": wm_memory::scrub_text(&hit.record.content),
1822 "score": hit.score,
1823 "matched_terms": hit.matched_terms,
1824 "session_id": hit.record.session_id,
1825 "sequence": hit.record.sequence,
1826 "created_at": hit.record.created_at,
1827 "validity": hit.record.validity,
1828 "provenance": hit.record.provenance,
1829 "source": "episodic",
1830 })
1831 })
1832 .collect::<Vec<_>>();
1833 Ok(json!({
1834 "status": "success",
1835 "count": results.len(),
1836 "current_resolution": wm_memory::episodic::is_current_query(query),
1840 "conflicts": conflicts,
1843 "results": results,
1844 }))
1845 }
1846 fn stats(&self) -> &ToolStats {
1847 &self.stats
1848 }
1849}
1850
1851pub struct MemoryAggregateTool {
1864 search: Option<Arc<SearchEngine>>,
1865 store: Arc<MemoryStore>,
1866 stats: ToolStats,
1867 effects: EffectRow,
1868}
1869
1870impl MemoryAggregateTool {
1871 pub fn new(search: Option<Arc<SearchEngine>>, store: Arc<MemoryStore>) -> Self {
1872 Self {
1873 search,
1874 store,
1875 stats: ToolStats::default(),
1876 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1877 }
1878 }
1879}
1880
1881fn session_ordinal(tags: &[String]) -> Option<u64> {
1883 tags.iter().find_map(|tag| {
1884 let rest = tag.strip_prefix("session_")?;
1885 rest.parse::<u64>().ok()
1886 })
1887}
1888
1889fn contains_term(content: &str, term: &str) -> bool {
1892 let lowered = content.to_ascii_lowercase();
1893 let variants = [term.to_string(), strip_suffix(term)];
1894 for variant in &variants {
1895 if variant.len() < 2 {
1896 continue;
1897 }
1898 let mut start = 0;
1899 while let Some(pos) = lowered[start..].find(variant.as_str()) {
1900 let before_ok = pos == 0
1901 || !lowered[start + pos - 1..start + pos]
1902 .chars()
1903 .next()
1904 .is_some_and(char::is_alphanumeric);
1905 let end = start + pos + variant.len();
1906 let after_ok = end >= lowered.len()
1907 || !lowered[end..]
1908 .chars()
1909 .next()
1910 .is_some_and(char::is_alphanumeric);
1911 if before_ok && after_ok {
1912 return true;
1913 }
1914 start += pos + variant.len();
1915 }
1916 }
1917 false
1918}
1919
1920fn strip_suffix(term: &str) -> String {
1923 for suffix in ["ing", "ed", "es", "s"] {
1924 if let Some(stem) = term.strip_suffix(suffix) {
1925 if stem.len() >= 2 {
1926 return stem.to_string();
1927 }
1928 }
1929 }
1930 term.to_string()
1931}
1932
1933#[async_trait]
1934impl Tool for MemoryAggregateTool {
1935 fn name(&self) -> &str {
1936 "memory.aggregate"
1937 }
1938 fn gana(&self) -> Gana {
1939 Gana::WinnowingBasket
1940 }
1941 fn effects(&self) -> &EffectRow {
1942 &self.effects
1943 }
1944 fn description(&self) -> &str {
1945 "Aggregate over memories matching a query: count, distinct session count, or session span (cross-session synthesis)"
1946 }
1947 fn input_schema(&self) -> Value {
1948 schema(
1949 &json!({
1950 "query": str_prop("Full-text query selecting the memories to aggregate over"),
1951 "metric": str_prop("Aggregate metric: count | session_count | session_span"),
1952 "limit": int_prop("Maximum candidates considered (default 50)"),
1953 }),
1954 &["query", "metric"],
1955 )
1956 }
1957 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1958 let query = args
1959 .get("query")
1960 .and_then(Value::as_str)
1961 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
1962 let metric = args
1963 .get("metric")
1964 .and_then(Value::as_str)
1965 .ok_or_else(|| wm_core::CoreError::InvalidArgs("metric (string) required".into()))?;
1966 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1967 let Some(search) = self.search.as_ref() else {
1968 return Err(wm_core::CoreError::Memory(
1969 "search engine unavailable for aggregation".into(),
1970 ));
1971 };
1972
1973 let results = search.search(query, limit)?;
1974 let mut memories = Vec::new();
1976 for r in &results {
1977 let Some(galaxy) = wm_core::Galaxy::from_db_name(&r.galaxy) else {
1978 continue;
1979 };
1980 let Ok(id) = uuid::Uuid::parse_str(&r.memory_id) else {
1981 continue;
1982 };
1983 let Ok(Some(mem)) = self.store.get(galaxy, id) else {
1984 continue;
1985 };
1986 if super::common::mcp_visible(&mem) && super::common::validity_visible(&mem) {
1987 memories.push((r.score, mem));
1988 }
1989 }
1990
1991 let evidence: Vec<Value> = memories
1992 .iter()
1993 .map(|(score, mem)| {
1994 json!({
1995 "memory_id": mem.metadata.id.to_string(),
1996 "score": score,
1997 "content": wm_memory::scrub_text(&mem.content),
1998 "tags": mem.metadata.tags,
1999 })
2000 })
2001 .collect();
2002
2003 let session_tagged: Vec<_> = memories
2012 .iter()
2013 .filter(|(_, mem)| session_ordinal(&mem.metadata.tags).is_some())
2014 .collect();
2015 let (anchored, anchor): (Vec<_>, &str) = if metric == "count" {
2016 (Vec::new(), "none")
2017 } else if session_tagged.len() < 2 {
2018 (session_tagged.clone(), "session_tagged_fallback")
2019 } else {
2020 let terms: Vec<String> = wm_memory::strip_stopwords(query)
2021 .split(|c: char| !c.is_alphanumeric())
2022 .filter(|t| t.len() > 1)
2023 .map(str::to_ascii_lowercase)
2024 .collect();
2025 let mut best: Option<(String, usize)> = None;
2026 for term in &terms {
2027 let count = session_tagged
2028 .iter()
2029 .filter(|(_, mem)| contains_term(&mem.content, term))
2030 .count();
2031 if count == 0 {
2032 continue;
2033 }
2034 let better = best
2035 .as_ref()
2036 .is_none_or(|(_, best_count)| count < *best_count);
2037 if better {
2038 best = Some((term.clone(), count));
2039 }
2040 }
2041 match best {
2042 Some((term, _)) => (
2043 session_tagged
2044 .iter()
2045 .filter(|(_, mem)| contains_term(&mem.content, &term))
2046 .copied()
2047 .collect(),
2048 "rarest_term",
2049 ),
2050 None => (session_tagged.clone(), "session_tagged_fallback"),
2051 }
2052 };
2053
2054 let aggregate = match metric {
2055 "count" => json!({
2056 "metric": "count",
2057 "value": memories.len(),
2058 "content": format!("{} memories", memories.len()),
2059 }),
2060 "session_count" => {
2061 let sessions: std::collections::HashSet<u64> = anchored
2062 .iter()
2063 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2064 .collect();
2065 json!({
2066 "metric": "session_count",
2067 "value": sessions.len(),
2068 "content": format!("{} distinct sessions", sessions.len()),
2069 })
2070 }
2071 "session_span" => {
2072 let ordinals: Vec<u64> = anchored
2073 .iter()
2074 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2075 .collect();
2076 if ordinals.is_empty() {
2077 json!({
2078 "metric": "session_span",
2079 "value": null,
2080 "content": "no session-tagged evidence found",
2081 })
2082 } else {
2083 let span = ordinals.iter().max().unwrap() - ordinals.iter().min().unwrap();
2084 json!({
2085 "metric": "session_span",
2086 "value": span,
2087 "unit": "sessions",
2088 "content": format!("{span} sessions"),
2089 })
2090 }
2091 }
2092 other => {
2093 return Err(wm_core::CoreError::InvalidArgs(format!(
2094 "unknown metric '{other}' (count | session_count | session_span)"
2095 )));
2096 }
2097 };
2098
2099 Ok(json!({
2100 "status": "success",
2101 "query": query,
2102 "total": memories.len(),
2103 "session_tagged": session_tagged.len(),
2104 "anchor": anchor,
2105 "limit_hit": results.len() >= limit,
2106 "aggregate": aggregate,
2107 "results": evidence,
2108 }))
2109 }
2110 fn stats(&self) -> &ToolStats {
2111 &self.stats
2112 }
2113}
2114
2115pub struct MemorySortTool {
2117 store: Arc<MemoryStore>,
2118 stats: ToolStats,
2119 effects: EffectRow,
2120}
2121
2122impl MemorySortTool {
2123 pub fn new(store: Arc<MemoryStore>) -> Self {
2124 Self {
2125 store,
2126 stats: ToolStats::default(),
2127 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2128 }
2129 }
2130}
2131
2132#[async_trait]
2133impl Tool for MemorySortTool {
2134 fn input_schema(&self) -> Value {
2135 schema(
2136 &json!({
2137 "galaxy": super::common::str_prop("Galaxy to sort (optional; default codex)"),
2138 "sort_by": super::common::str_prop("Sort key: importance | created_at | accessed_at | access_count"),
2139 "order": super::common::str_prop("Order: asc | desc (default desc)"),
2140 "limit": super::common::int_prop("Maximum entries (default 50)"),
2141 }),
2142 &[],
2143 )
2144 }
2145 fn name(&self) -> &str {
2146 "memory.sort"
2147 }
2148 fn gana(&self) -> Gana {
2149 Gana::WinnowingBasket
2150 }
2151 fn effects(&self) -> &EffectRow {
2152 &self.effects
2153 }
2154 fn description(&self) -> &str {
2155 "Sort memories by importance, recency, or access count"
2156 }
2157 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2158 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2159 let sort_by = args
2160 .get("sort_by")
2161 .and_then(|v| v.as_str())
2162 .unwrap_or("importance");
2163 let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
2164 let limit = args
2165 .get("limit")
2166 .and_then(serde_json::Value::as_u64)
2167 .unwrap_or(50) as usize;
2168
2169 let mut memories = self.store.scan(galaxy, 10_000)?;
2170 memories.retain(|m| {
2173 crate::expansion::common::mcp_visible(m)
2174 && crate::expansion::common::validity_visible(m)
2175 });
2176
2177 match sort_by {
2178 "importance" => memories.sort_by(|a, b| {
2179 b.metadata
2180 .importance
2181 .partial_cmp(&a.metadata.importance)
2182 .unwrap_or(std::cmp::Ordering::Equal)
2183 }),
2184 "recency" => memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.created_at)),
2185 "accessed" => {
2186 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.accessed_at));
2187 }
2188 "access_count" => {
2189 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.access_count));
2190 }
2191 _ => {
2192 return Err(wm_core::CoreError::InvalidArgs(format!(
2193 "Unknown sort_by: '{sort_by}'. Use importance, recency, accessed, or access_count"
2194 )));
2195 }
2196 }
2197
2198 if order == "asc" {
2199 memories.reverse();
2200 }
2201
2202 let total = memories.len();
2203 memories.truncate(limit);
2204
2205 let results: Vec<Value> = memories
2206 .iter()
2207 .map(|m| {
2208 json!({
2209 "id": m.metadata.id,
2210 "content": &m.content,
2211 "importance": m.metadata.importance,
2212 "created_at": m.metadata.created_at.to_rfc3339(),
2213 "accessed_at": m.metadata.accessed_at.to_rfc3339(),
2214 "access_count": m.metadata.access_count,
2215 "tags": &m.metadata.tags,
2216 })
2217 })
2218 .collect();
2219
2220 Ok(json!({
2221 "status": "success",
2222 "galaxy": galaxy_name(galaxy),
2223 "sort_by": sort_by,
2224 "order": order,
2225 "total": total,
2226 "returned": results.len(),
2227 "memories": results,
2228 }))
2229 }
2230 fn stats(&self) -> &ToolStats {
2231 &self.stats
2232 }
2233}
2234
2235pub struct MemoryFilterTool {
2237 store: Arc<MemoryStore>,
2238 stats: ToolStats,
2239 effects: EffectRow,
2240}
2241
2242impl MemoryFilterTool {
2243 pub fn new(store: Arc<MemoryStore>) -> Self {
2244 Self {
2245 store,
2246 stats: ToolStats::default(),
2247 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2248 }
2249 }
2250}
2251
2252#[async_trait]
2253impl Tool for MemoryFilterTool {
2254 fn name(&self) -> &str {
2255 "memory.filter"
2256 }
2257 fn gana(&self) -> Gana {
2258 Gana::WinnowingBasket
2259 }
2260 fn effects(&self) -> &EffectRow {
2261 &self.effects
2262 }
2263 fn description(&self) -> &str {
2264 "Filter memories by tags, date range, importance thresholds, and a content query substring"
2265 }
2266 fn input_schema(&self) -> Value {
2267 super::common::schema(
2268 &json!({
2269 "galaxy": super::common::str_prop("Galaxy to filter (default codex)"),
2270 "tags": super::common::str_array_prop("Filter: memories with all of these tags"),
2271 "exclude_tags": super::common::str_array_prop("Filter: drop memories carrying any of these tags"),
2272 "min_importance": super::common::num_prop("Filter: minimum importance (0-1)"),
2273 "max_importance": super::common::num_prop("Filter: maximum importance (0-1)"),
2274 "query": super::common::str_prop("Filter: every whitespace-separated term must appear (case-insensitive) in the content or title"),
2275 "created_after": super::common::str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
2276 "created_before": super::common::str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
2277 "limit": super::common::int_prop("Maximum entries (default 50)"),
2278 "offset": super::common::int_prop("Skip this many matching entries before returning (default 0)"),
2279 }),
2280 &[],
2281 )
2282 }
2283 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2284 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2285 let tags: Vec<String> = args
2286 .get("tags")
2287 .and_then(|v| v.as_array())
2288 .map(|arr| {
2289 arr.iter()
2290 .filter_map(|t| t.as_str().map(String::from))
2291 .collect()
2292 })
2293 .unwrap_or_default();
2294 let exclude_tags: Vec<String> = args
2295 .get("exclude_tags")
2296 .and_then(|v| v.as_array())
2297 .map(|arr| {
2298 arr.iter()
2299 .filter_map(|t| t.as_str().map(String::from))
2300 .collect()
2301 })
2302 .unwrap_or_default();
2303 let min_importance = args
2304 .get("min_importance")
2305 .and_then(serde_json::Value::as_f64)
2306 .unwrap_or(0.0) as f32;
2307 let max_importance = args
2308 .get("max_importance")
2309 .and_then(serde_json::Value::as_f64)
2310 .unwrap_or(1.0) as f32;
2311 let limit = args
2312 .get("limit")
2313 .and_then(serde_json::Value::as_u64)
2314 .unwrap_or(50) as usize;
2315 let offset = args
2316 .get("offset")
2317 .and_then(serde_json::Value::as_u64)
2318 .unwrap_or(0) as usize;
2319 let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
2322 match args.get(name).and_then(|v| v.as_str()) {
2323 Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
2324 .map(|t| Some(t.with_timezone(&chrono::Utc)))
2325 .map_err(|_| {
2326 wm_core::CoreError::InvalidArgs(format!(
2327 "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
2328 ))
2329 }),
2330 _ => Ok(None),
2331 }
2332 };
2333 let created_after = parse_bound("created_after")?;
2334 let created_before = parse_bound("created_before")?;
2335 let query_terms: Vec<String> = args
2339 .get("query")
2340 .and_then(|v| v.as_str())
2341 .map(|q| q.split_whitespace().map(str::to_lowercase).collect())
2342 .unwrap_or_default();
2343
2344 let memories = self.store.scan(galaxy, 10_000)?;
2345
2346 let matched: Vec<&wm_memory::Memory> = memories
2347 .iter()
2348 .filter(|m| {
2349 if !crate::expansion::common::mcp_visible(m) {
2351 return false;
2352 }
2353 if !crate::expansion::common::validity_visible(m) {
2355 return false;
2356 }
2357 if m.metadata.importance < min_importance || m.metadata.importance > max_importance
2358 {
2359 return false;
2360 }
2361 if !tags.is_empty() && !tags.iter().all(|t| m.metadata.tags.contains(t)) {
2362 return false;
2363 }
2364 if exclude_tags
2365 .iter()
2366 .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
2367 {
2368 return false;
2369 }
2370 if let Some(after) = created_after {
2371 if m.metadata.created_at < after {
2372 return false;
2373 }
2374 }
2375 if let Some(before) = created_before {
2376 if m.metadata.created_at > before {
2377 return false;
2378 }
2379 }
2380 if !query_terms.is_empty() {
2381 let haystack = match &m.metadata.title {
2382 Some(title) => format!("{}\n{}", m.content, title).to_lowercase(),
2383 None => m.content.to_lowercase(),
2384 };
2385 if !query_terms.iter().all(|t| haystack.contains(t)) {
2386 return false;
2387 }
2388 }
2389 true
2390 })
2391 .collect();
2392 let filtered: Vec<&&wm_memory::Memory> = matched.iter().skip(offset).take(limit).collect();
2394
2395 let total_scanned = memories.len();
2396 let results: Vec<Value> = filtered
2397 .iter()
2398 .map(|m| {
2399 json!({
2400 "id": m.metadata.id,
2401 "content": &m.content,
2402 "importance": m.metadata.importance,
2403 "tags": &m.metadata.tags,
2404 "created_at": m.metadata.created_at.to_rfc3339(),
2405 })
2406 })
2407 .collect();
2408
2409 Ok(json!({
2410 "status": "success",
2411 "galaxy": galaxy_name(galaxy),
2412 "scanned": total_scanned,
2413 "matched": matched.len(),
2414 "offset": offset,
2415 "returned": results.len(),
2416 "filters": {
2417 "tags": tags,
2418 "exclude_tags": exclude_tags,
2419 "min_importance": min_importance,
2420 "max_importance": max_importance,
2421 "query_terms": query_terms,
2422 "created_after": created_after.map(|t| t.to_rfc3339()),
2423 "created_before": created_before.map(|t| t.to_rfc3339()),
2424 },
2425 "memories": results,
2426 }))
2427 }
2428 fn stats(&self) -> &ToolStats {
2429 &self.stats
2430 }
2431}
2432
2433pub struct MemoryDeduplicateTool {
2435 store: Arc<MemoryStore>,
2436 search: Option<Arc<SearchEngine>>,
2437 stats: ToolStats,
2438 effects: EffectRow,
2439}
2440
2441impl MemoryDeduplicateTool {
2442 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
2443 Self {
2444 store,
2445 search,
2446 stats: ToolStats::default(),
2447 effects: EffectRow {
2448 writes: super::common::memory_galaxy_writes(),
2449 reads: super::common::memory_galaxy_reads(),
2450 destructive: true,
2451 ..Default::default()
2452 },
2453 }
2454 }
2455}
2456
2457#[async_trait]
2458impl Tool for MemoryDeduplicateTool {
2459 fn name(&self) -> &str {
2460 "memory.deduplicate"
2461 }
2462 fn gana(&self) -> Gana {
2463 Gana::WinnowingBasket
2464 }
2465 fn effects(&self) -> &EffectRow {
2466 &self.effects
2467 }
2468 fn description(&self) -> &str {
2469 "Find and merge duplicate memories by content hash or similarity"
2470 }
2471 fn input_schema(&self) -> Value {
2472 super::common::schema(
2473 &json!({
2474 "galaxy": super::common::str_prop("Galaxy to deduplicate"),
2475 "mode": super::common::str_prop("Strategy: hash | similarity (default: hash)"),
2476 "limit": super::common::int_prop("Maximum entries to scan"),
2477 "dry_run": super::common::bool_prop("Preview only (default: true)"),
2478 }),
2479 &["galaxy"],
2480 )
2481 }
2482 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2483 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2484 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("hash");
2485 let dry_run = args
2486 .get("dry_run")
2487 .and_then(serde_json::Value::as_bool)
2488 .unwrap_or(true);
2489 let limit = args
2490 .get("limit")
2491 .and_then(serde_json::Value::as_u64)
2492 .unwrap_or(10_000) as usize;
2493
2494 let memories = self.store.scan(galaxy, limit)?;
2495
2496 match mode {
2497 "hash" => {
2498 let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
2499 let mut duplicates: Vec<Value> = Vec::new();
2500
2501 for mem in &memories {
2502 let hash = &mem.metadata.content_hash;
2503 if let Some(existing_id) = seen_hashes.get(hash) {
2504 if *existing_id != mem.metadata.id {
2505 duplicates.push(json!({
2506 "id": mem.metadata.id,
2507 "duplicate_of": existing_id,
2508 "content_preview": mem.content.chars().take(100).collect::<String>(),
2509 "importance": mem.metadata.importance,
2510 }));
2511 if !dry_run {
2512 self.store.delete(galaxy, mem.metadata.id)?;
2513 super::common::deindex(
2514 self.search.as_deref(),
2515 &mem.metadata.id.to_string(),
2516 );
2517 }
2518 }
2519 } else {
2520 seen_hashes.insert(hash.clone(), mem.metadata.id);
2521 }
2522 }
2523
2524 let removed = if dry_run { 0 } else { duplicates.len() };
2525
2526 Ok(json!({
2527 "status": "success",
2528 "galaxy": galaxy_name(galaxy),
2529 "mode": mode,
2530 "dry_run": dry_run,
2531 "scanned": memories.len(),
2532 "duplicates_found": duplicates.len(),
2533 "removed": removed,
2534 "duplicates": duplicates,
2535 }))
2536 }
2537 "content" => {
2538 let mut duplicates: Vec<Value> = Vec::new();
2539 let mut removed_count = 0u32;
2540
2541 for i in 0..memories.len() {
2542 for j in (i + 1)..memories.len() {
2543 if memories[i].content == memories[j].content {
2544 duplicates.push(json!({
2545 "id": memories[j].metadata.id,
2546 "duplicate_of": memories[i].metadata.id,
2547 "content_preview": memories[j].content.chars().take(100).collect::<String>(),
2548 }));
2549 if !dry_run {
2550 self.store.delete(galaxy, memories[j].metadata.id)?;
2551 super::common::deindex(
2552 self.search.as_deref(),
2553 &memories[j].metadata.id.to_string(),
2554 );
2555 removed_count += 1;
2556 }
2557 break;
2558 }
2559 }
2560 }
2561
2562 Ok(json!({
2563 "status": "success",
2564 "galaxy": galaxy_name(galaxy),
2565 "mode": mode,
2566 "dry_run": dry_run,
2567 "scanned": memories.len(),
2568 "duplicates_found": duplicates.len(),
2569 "removed": removed_count,
2570 "duplicates": duplicates,
2571 }))
2572 }
2573 _ => Err(wm_core::CoreError::InvalidArgs(format!(
2574 "Unknown mode: '{mode}'. Use 'hash' or 'content'"
2575 ))),
2576 }
2577 }
2578 fn stats(&self) -> &ToolStats {
2579 &self.stats
2580 }
2581}
2582
2583pub struct MemoryExportTool {
2585 store: Arc<MemoryStore>,
2586 stats: ToolStats,
2587 effects: EffectRow,
2588}
2589
2590impl MemoryExportTool {
2591 pub fn new(store: Arc<MemoryStore>) -> Self {
2592 Self {
2593 store,
2594 stats: ToolStats::default(),
2595 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2596 }
2597 }
2598}
2599
2600#[async_trait]
2601impl Tool for MemoryExportTool {
2602 fn input_schema(&self) -> Value {
2603 schema(
2604 &json!({
2605 "galaxy": super::common::str_prop("Galaxy to export (optional; default codex)"),
2606 "format": super::common::str_prop("Export format: json | jsonl | markdown"),
2607 "limit": super::common::int_prop("Maximum entries to export"),
2608 }),
2609 &[],
2610 )
2611 }
2612 fn name(&self) -> &str {
2613 "memory.export"
2614 }
2615 fn gana(&self) -> Gana {
2616 Gana::WinnowingBasket
2617 }
2618 fn effects(&self) -> &EffectRow {
2619 &self.effects
2620 }
2621 fn description(&self) -> &str {
2622 "Export memories in JSON, CSV, or Markdown format"
2623 }
2624 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2625 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2626 let format = args
2627 .get("format")
2628 .and_then(|v| v.as_str())
2629 .unwrap_or("json");
2630 let limit = args
2631 .get("limit")
2632 .and_then(serde_json::Value::as_u64)
2633 .unwrap_or(1000) as usize;
2634
2635 let memories = self.store.scan(galaxy, limit)?;
2636
2637 let exported = match format {
2638 "json" => {
2639 let entries: Vec<Value> = memories
2640 .iter()
2641 .map(|m| {
2642 json!({
2643 "id": m.metadata.id,
2644 "content": &m.content,
2645 "tags": &m.metadata.tags,
2646 "importance": m.metadata.importance,
2647 "created_at": m.metadata.created_at.to_rfc3339(),
2648 "access_count": m.metadata.access_count,
2649 })
2650 })
2651 .collect();
2652 serde_json::to_string_pretty(&entries).unwrap_or_default()
2653 }
2654 "csv" => {
2655 let mut csv = String::from("id,content,tags,importance,created_at,access_count\n");
2656 for m in &memories {
2657 let tags = m.metadata.tags.join(";");
2658 let content = m.content.replace('\n', " ").replace('"', "'");
2659 let _ = writeln!(
2660 csv,
2661 "{},{},{},{:.3},{},{}",
2662 m.metadata.id,
2663 content,
2664 tags,
2665 m.metadata.importance,
2666 m.metadata.created_at.to_rfc3339(),
2667 m.metadata.access_count,
2668 );
2669 }
2670 csv
2671 }
2672 "markdown" => {
2673 let mut md = format!("# Memory Export: {}\n\n", galaxy_name(galaxy));
2674 let _ = write!(md, "Total memories: {}\n\n", memories.len());
2675 for m in &memories {
2676 let _ = write!(
2677 md,
2678 "## {}\n\n- **Importance**: {:.2}\n- **Tags**: {}\n- **Created**: {}\n- **Access Count**: {}\n\n{}\n\n---\n\n",
2679 m.metadata.id,
2680 m.metadata.importance,
2681 m.metadata.tags.join(", "),
2682 m.metadata.created_at.to_rfc3339(),
2683 m.metadata.access_count,
2684 m.content,
2685 );
2686 }
2687 md
2688 }
2689 _ => {
2690 return Err(wm_core::CoreError::InvalidArgs(format!(
2691 "Unknown format: '{format}'. Use json, csv, or markdown"
2692 )));
2693 }
2694 };
2695
2696 Ok(json!({
2697 "status": "success",
2698 "galaxy": galaxy_name(galaxy),
2699 "format": format,
2700 "count": memories.len(),
2701 "export": exported,
2702 }))
2703 }
2704 fn stats(&self) -> &ToolStats {
2705 &self.stats
2706 }
2707}
2708
2709#[cfg(test)]
2710mod tests {
2711 use super::*;
2712 use wm_core::{EpisodicKind, EpisodicRecord, Galaxy, Provenance, ProvenanceSource};
2713 use wm_memory::{Association, AssociationStore, LinkType, Memory, MemoryStore};
2714
2715 fn test_store() -> Arc<MemoryStore> {
2716 let dir = tempfile::tempdir().unwrap();
2717 Arc::new(MemoryStore::open_default(dir.path()).unwrap())
2718 }
2719
2720 fn populate_memories(store: &Arc<MemoryStore>, galaxy: Galaxy) {
2721 let mut m1 = Memory::new(galaxy, "First memory about rust".into());
2722 m1.metadata.importance = 0.9;
2723 m1.metadata.tags = vec!["rust".into(), "programming".into()];
2724 let _ = store.put(galaxy, &m1);
2725
2726 let mut m2 = Memory::new(galaxy, "Second memory about python".into());
2727 m2.metadata.importance = 0.5;
2728 m2.metadata.tags = vec!["python".into()];
2729 let _ = store.put(galaxy, &m2);
2730
2731 let mut m3 = Memory::new(galaxy, "Third memory about rust".into());
2732 m3.metadata.importance = 0.3;
2733 m3.metadata.tags = vec!["rust".into(), "tutorial".into()];
2734 let _ = store.put(galaxy, &m3);
2735 }
2736
2737 #[tokio::test]
2738 async fn episodic_search_filters_private_records() {
2739 let store = test_store();
2740 let public = EpisodicRecord::new(
2741 None,
2742 1,
2743 EpisodicKind::Observation,
2744 "public retrieval evidence",
2745 Provenance::new(ProvenanceSource::User),
2746 );
2747 let private = EpisodicRecord::new(
2748 None,
2749 2,
2750 EpisodicKind::Observation,
2751 "private retrieval evidence",
2752 Provenance::new(ProvenanceSource::User),
2753 )
2754 .with_visibility(true, false);
2755 store.episodic().append(&public).unwrap();
2756 store.episodic().append(&private).unwrap();
2757
2758 let tool = MemoryEpisodicSearchTool::new(store);
2759 let mut ctx = Context::default();
2760 let result = tool
2761 .call(
2762 &mut ctx,
2763 json!({"query": "retrieval evidence", "limit": 10}),
2764 )
2765 .await
2766 .unwrap();
2767 assert_eq!(result["count"], 1);
2768 assert_eq!(result["results"][0]["id"], json!(public.id));
2769 }
2770
2771 fn mirror_memory(
2774 store: &Arc<MemoryStore>,
2775 mem: &Memory,
2776 session: Option<uuid::Uuid>,
2777 sequence: u64,
2778 ) {
2779 use wm_core::EpisodicCapturePolicy;
2780 let record = EpisodicRecord::new(
2781 session,
2782 sequence,
2783 EpisodicKind::Observation,
2784 mem.content.clone(),
2785 Provenance::new(ProvenanceSource::User),
2786 )
2787 .with_id(mem.metadata.id)
2788 .with_visibility(mem.metadata.is_private, mem.metadata.model_exclude);
2789 store
2790 .episodic()
2791 .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
2792 .unwrap();
2793 }
2794
2795 fn default_search_tool(
2796 store: Arc<MemoryStore>,
2797 search: Option<Arc<SearchEngine>>,
2798 ) -> MemoryHybridRecallTool {
2799 MemoryHybridRecallTool::as_search(store, search, None)
2800 }
2801
2802 #[tokio::test]
2803 async fn default_route_prefers_episodic_and_discloses_mode() {
2804 let (_dir, store, search) = hybrid_fixture();
2808 let needle = Memory::new(
2809 Galaxy::Codex,
2810 "Kotlin coroutine budget meeting notes".into(),
2811 );
2812 let needle_id = needle.metadata.id;
2813 let other = Memory::new(Galaxy::Codex, "Grocery list eggs and flour".into());
2814 store.put(Galaxy::Codex, &needle).unwrap();
2815 store.put(Galaxy::Codex, &other).unwrap();
2816 mirror_memory(&store, &needle, None, 1);
2817 mirror_memory(&store, &other, None, 2);
2818
2819 let tool = default_search_tool(store.clone(), Some(search));
2820 let mut ctx = Context::default();
2821 let v = tool
2822 .call(
2823 &mut ctx,
2824 json!({"query": "kotlin coroutine budget", "limit": 10}),
2825 )
2826 .await
2827 .unwrap();
2828 assert_eq!(v["recall_mode"], "episodic");
2829 assert_eq!(v["results"][0]["source"], "episodic");
2830 assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
2831 assert!(v["results"][0]["score"].as_f64().unwrap() > 0.0);
2832 }
2833
2834 #[tokio::test]
2835 async fn default_route_matches_the_episodic_machinery_ranking() {
2836 let (_dir, store, search) = hybrid_fixture();
2840 let contents = [
2841 "Deployed the telemetry agent on Tuesday",
2842 "Cancun trip booked for the twelfth",
2843 "Telemetry agent rollout postponed to Friday",
2844 "Deadline for the quarterly report moved",
2845 ];
2846 let mut memories: Vec<(uuid::Uuid, &str)> = Vec::new();
2847 for (i, content) in contents.iter().enumerate() {
2848 let mem = Memory::new(Galaxy::Codex, (*content).to_string());
2849 memories.push((mem.metadata.id, content));
2850 store.put(Galaxy::Codex, &mem).unwrap();
2851 mirror_memory(&store, &mem, None, i as u64 + 1);
2852 }
2853 let query = "when was the telemetry agent deployed";
2854
2855 let default_tool = default_search_tool(store.clone(), Some(search.clone()));
2856 let mut ctx = Context::default();
2857 let default_v = default_tool
2858 .call(&mut ctx, json!({"query": query, "limit": 10}))
2859 .await
2860 .unwrap();
2861 let episodic_tool = MemoryEpisodicSearchTool::new(store);
2862 let episodic_v = episodic_tool
2863 .call(&mut ctx, json!({"query": query, "limit": 10}))
2864 .await
2865 .unwrap();
2866 assert_eq!(
2867 default_v["results"][0]["id"], episodic_v["results"][0]["id"],
2868 "default route must rank exactly like the episodic machinery"
2869 );
2870 let top = memories
2871 .iter()
2872 .find(|(id, _)| id.to_string() == default_v["results"][0]["id"])
2873 .map(|(_, c)| *c)
2874 .unwrap();
2875 assert_eq!(top, "Deployed the telemetry agent on Tuesday");
2876 }
2877
2878 #[tokio::test]
2879 async fn default_route_falls_back_to_fts_when_episodic_yields_nothing() {
2880 let (_dir, store, search) = hybrid_fixture();
2883 let mem = Memory::new(Galaxy::Codex, "Zebra quotas revised upward".into());
2884 let id = mem.metadata.id;
2885 store.put(Galaxy::Codex, &mem).unwrap();
2886 {
2887 let mut writer = search.writer().unwrap();
2888 search
2889 .add_document(
2890 &mut writer,
2891 &id.to_string(),
2892 "codex",
2893 "Zebra quotas revised upward",
2894 &mem.metadata.tags,
2895 mem.metadata.created_at.timestamp(),
2896 )
2897 .unwrap();
2898 search.commit(&mut writer).unwrap();
2899 }
2900
2901 let tool = default_search_tool(store, Some(search));
2902 let mut ctx = Context::default();
2903 let v = tool
2904 .call(&mut ctx, json!({"query": "zebra quotas", "limit": 10}))
2905 .await
2906 .unwrap();
2907 assert_eq!(v["recall_mode"], "fts");
2908 assert_eq!(v["results"][0]["source"], "fts");
2909 assert_eq!(v["results"][0]["id"], json!(id.to_string()));
2910 }
2911
2912 #[tokio::test]
2913 async fn episodic_default_route_respects_galaxy_filter() {
2914 let (_dir, store, search) = hybrid_fixture();
2915 let in_galaxy = Memory::new(Galaxy::Codex, "Marble fountain restoration plan".into());
2916 let other_galaxy =
2917 Memory::new(Galaxy::Sessions, "Marble fountain restoration notes".into());
2918 store.put(Galaxy::Codex, &in_galaxy).unwrap();
2919 store.put(Galaxy::Sessions, &other_galaxy).unwrap();
2920 mirror_memory(&store, &in_galaxy, None, 1);
2921 mirror_memory(&store, &other_galaxy, None, 2);
2922
2923 let tool = default_search_tool(store, Some(search));
2924 let mut ctx = Context::default();
2925 let v = tool
2926 .call(
2927 &mut ctx,
2928 json!({"query": "marble fountain", "galaxy": "sessions", "limit": 10}),
2929 )
2930 .await
2931 .unwrap();
2932 assert_eq!(v["recall_mode"], "episodic");
2933 for r in v["results"].as_array().unwrap() {
2934 assert_eq!(r["galaxy"], "sessions", "galaxy filter must hold");
2935 }
2936 assert_eq!(
2937 v["results"][0]["id"],
2938 json!(other_galaxy.metadata.id.to_string())
2939 );
2940 }
2941
2942 #[tokio::test]
2943 async fn episodic_default_route_filters_private_and_stale() {
2944 let (_dir, store, search) = hybrid_fixture();
2945 let public = Memory::new(
2946 Galaxy::Codex,
2947 "Lighthouse maintenance schedule confirmed".into(),
2948 );
2949 let mut private = Memory::new(Galaxy::Codex, "Lighthouse access code renewal".into());
2950 private.metadata.is_private = true;
2951 let stale = Memory::new(Galaxy::Codex, "Lighthouse inspection legacy draft".into());
2952 let stale_id = stale.metadata.id;
2953 store.put(Galaxy::Codex, &public).unwrap();
2954 store.put(Galaxy::Codex, &private).unwrap();
2955 store.put(Galaxy::Codex, &stale).unwrap();
2956 mirror_memory(&store, &public, None, 1);
2957 mirror_memory(&store, &private, None, 2);
2958 mirror_memory(&store, &stale, None, 3);
2959 store.delete(Galaxy::Codex, stale_id).unwrap();
2962
2963 let tool = default_search_tool(store, Some(search));
2964 let mut ctx = Context::default();
2965 let v = tool
2966 .call(&mut ctx, json!({"query": "lighthouse", "limit": 10}))
2967 .await
2968 .unwrap();
2969 assert_eq!(v["recall_mode"], "episodic");
2970 let ids: Vec<&str> = v["results"]
2971 .as_array()
2972 .unwrap()
2973 .iter()
2974 .filter_map(|r| r["id"].as_str())
2975 .collect();
2976 assert!(
2977 !ids.contains(&private.metadata.id.to_string().as_str()),
2978 "private memories must never surface on the default route"
2979 );
2980 assert!(
2981 !ids.contains(&stale_id.to_string().as_str()),
2982 "episodic records without a live v5 memory must be skipped"
2983 );
2984 assert!(!ids.is_empty(), "the public hit must still surface");
2985 }
2986
2987 #[tokio::test]
2988 async fn memory_sort_by_importance_desc() {
2989 let store = test_store();
2990 populate_memories(&store, Galaxy::Codex);
2991 let tool = MemorySortTool::new(store);
2992 let mut ctx = Context::default();
2993 let v = tool
2994 .call(&mut ctx, json!({"sort_by": "importance", "order": "desc"}))
2995 .await
2996 .unwrap();
2997 assert_eq!(v["status"], "success");
2998 assert_eq!(v["returned"], 3);
2999 let mems = v["memories"].as_array().unwrap();
3000 assert!(mems[0]["importance"].as_f64().unwrap() >= mems[1]["importance"].as_f64().unwrap());
3001 }
3002
3003 #[tokio::test]
3004 async fn memory_update_cannot_mutate_tier() {
3005 let store = test_store();
3009 let mem = Memory::new(Galaxy::Codex, "tier is not client-settable".into());
3010 let id = mem.metadata.id;
3011 store.put(Galaxy::Codex, &mem).unwrap();
3012
3013 let tool = MemoryUpdateTool::new(store.clone(), None);
3014 let mut ctx = Context::default();
3015 let v = tool
3016 .call(
3017 &mut ctx,
3018 json!({"galaxy": "codex", "id": id.to_string(), "tier": "archival", "tags": ["x"]}),
3019 )
3020 .await
3021 .unwrap();
3022 assert_eq!(v["status"], "success");
3023
3024 let after = store.get(Galaxy::Codex, id).unwrap().unwrap();
3025 assert_eq!(
3026 after.metadata.tier,
3027 wm_memory::Tier::Working,
3028 "memory.update must never move the tier"
3029 );
3030 assert_eq!(
3031 after.metadata.tags,
3032 vec!["x".to_string()],
3033 "whitelisted fields still apply"
3034 );
3035 }
3036
3037 #[tokio::test]
3038 async fn empty_search_hints_at_populated_galaxies() {
3039 let (_dir, store, search) = hybrid_fixture();
3044 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3045 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3046 let mut ctx = Context::default();
3047 let v = tool
3048 .call(&mut ctx, json!({"query": "gate plan"}))
3049 .await
3050 .unwrap();
3051 assert_eq!(
3052 v["count"], 1,
3053 "unfiltered search must find cross-galaxy content: {v}"
3054 );
3055 assert_eq!(v["galaxy"], "all");
3056 assert_eq!(v["results"][0]["galaxy"], "sessions");
3057 assert!(v["hint"].is_null());
3058
3059 let v2 = tool
3061 .call(
3062 &mut ctx,
3063 json!({"query": "zzz-no-match", "galaxy": "sessions"}),
3064 )
3065 .await
3066 .unwrap();
3067 assert_eq!(v2["count"], 0);
3068 let hint2 = v2["hint"].as_str().unwrap();
3069 assert!(hint2.contains("no matches for this query"), "{hint2}");
3070
3071 let v3 = tool
3074 .call(&mut ctx, json!({"query": "zzz-no-match"}))
3075 .await
3076 .unwrap();
3077 assert_eq!(v3["count"], 0);
3078 let hint3 = v3["hint"].as_str().expect("hint present on empty result");
3079 assert!(hint3.contains("across all memory galaxies"), "{hint3}");
3080 }
3081
3082 #[tokio::test]
3083 async fn unfiltered_search_labels_hits_from_every_galaxy() {
3084 let (_dir, store, search) = hybrid_fixture();
3089 index_memory(
3090 &store,
3091 &search,
3092 Galaxy::Sessions,
3093 "lineage ledger phase four",
3094 );
3095 index_memory(
3096 &store,
3097 &search,
3098 Galaxy::Codex,
3099 "lineage ledger codex mirror note",
3100 );
3101 index_memory(&store, &search, Galaxy::Dreams, "lineage ledger dream echo");
3102 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3103 let mut ctx = Context::default();
3104 let v = tool
3105 .call(&mut ctx, json!({"query": "lineage ledger", "limit": 10}))
3106 .await
3107 .unwrap();
3108 assert_eq!(v["count"], 3, "got: {v}");
3109 let galaxies: Vec<&str> = v["results"]
3110 .as_array()
3111 .unwrap()
3112 .iter()
3113 .map(|r| r["galaxy"].as_str().unwrap())
3114 .collect();
3115 assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
3116 assert!(galaxies.contains(&"codex"), "got: {galaxies:?}");
3117 assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
3118
3119 let v2 = tool
3121 .call(
3122 &mut ctx,
3123 json!({"query": "lineage ledger", "galaxy": "dreams"}),
3124 )
3125 .await
3126 .unwrap();
3127 assert_eq!(v2["count"], 1, "got: {v2}");
3128 assert_eq!(v2["results"][0]["galaxy"], "dreams");
3129 }
3130
3131 #[tokio::test]
3132 async fn successful_search_carries_no_hint() {
3133 let (_dir, store, search) = hybrid_fixture();
3134 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3135 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3136 let mut ctx = Context::default();
3137 let v = tool
3138 .call(
3139 &mut ctx,
3140 json!({"query": "gate plan", "galaxy": "sessions"}),
3141 )
3142 .await
3143 .unwrap();
3144 assert_eq!(v["count"], 1);
3145 assert!(v["hint"].is_null());
3146 }
3147
3148 #[tokio::test]
3149 async fn associative_expansion_surfaces_linked_memory() {
3150 let dir = tempfile::tempdir().unwrap();
3154 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3155 let tantivy_dir = dir.path().join("tantivy");
3156 std::fs::create_dir_all(&tantivy_dir).unwrap();
3157 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3158 index_memory(
3159 &store,
3160 &search,
3161 Galaxy::Codex,
3162 "gate plan for the v7 alpha release",
3163 );
3164 let mut linked = Memory::new(
3165 Galaxy::Codex,
3166 "backup automation runs nightly at 03:30".into(),
3167 );
3168 linked.metadata.importance = 0.7;
3169 let linked_id = linked.metadata.id;
3170 store.put(Galaxy::Codex, &linked).unwrap();
3171 search
3172 .writer()
3173 .and_then(|mut w| {
3174 search.add_document(
3175 &mut w,
3176 &linked_id.to_string(),
3177 "codex",
3178 &linked.content,
3179 &linked.metadata.tags,
3180 linked.metadata.created_at.timestamp(),
3181 )?;
3182 search.commit(&mut w)
3183 })
3184 .unwrap();
3185
3186 let assoc = Association::new(
3188 find_id(&store, "gate plan"),
3189 linked_id,
3190 LinkType::Extends,
3191 0.8,
3192 );
3193 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3194 associations.put(store.env(), &assoc).unwrap();
3195
3196 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3197 .with_associations(Some(associations));
3198 let mut ctx = Context::default();
3199 let v = tool
3200 .call(&mut ctx, json!({"query": "gate plan alpha release"}))
3201 .await
3202 .unwrap();
3203 assert_eq!(v["count"], 2, "direct hit + associated memory: {v}");
3204 let assoc_hit = v["results"]
3205 .as_array()
3206 .unwrap()
3207 .iter()
3208 .find(|r| r["source"] == "association")
3209 .expect("association-sourced result present");
3210 assert_eq!(assoc_hit["id"], json!(linked_id.to_string()));
3211 assert_eq!(assoc_hit["link_type"], "extends");
3212 assert!(assoc_hit["via"].is_string());
3213 assert!(assoc_hit["weight"].as_f64().unwrap() > 0.7);
3214 }
3215
3216 fn find_id(store: &MemoryStore, needle: &str) -> uuid::Uuid {
3217 store
3218 .scan(Galaxy::Codex, 100)
3219 .unwrap()
3220 .into_iter()
3221 .find(|m| m.content.contains(needle))
3222 .map(|m| m.metadata.id)
3223 .unwrap()
3224 }
3225
3226 #[tokio::test]
3227 async fn associative_expansion_skips_private_and_dedupes() {
3228 let dir = tempfile::tempdir().unwrap();
3229 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3230 let mut a = Memory::new(Galaxy::Codex, "quarterly revenue planning notes".into());
3231 a.metadata.importance = 0.8;
3232 let a_id = a.metadata.id;
3233 store.put(Galaxy::Codex, &a).unwrap();
3234 let mut private = Memory::new(Galaxy::Codex, "private salary bands".into());
3236 private.metadata.is_private = true;
3237 private.metadata.importance = 0.8;
3238 store.put(Galaxy::Codex, &private).unwrap();
3239 let mut b = Memory::new(Galaxy::Codex, "hiring plan for next quarter".into());
3241 b.metadata.importance = 0.7;
3242 let b_id = b.metadata.id;
3243 store.put(Galaxy::Codex, &b).unwrap();
3244
3245 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3246 associations
3247 .put(
3248 store.env(),
3249 &Association::new(a_id, private.metadata.id, LinkType::Related, 0.9),
3250 )
3251 .unwrap();
3252 associations
3253 .put(
3254 store.env(),
3255 &Association::new(a_id, b_id, LinkType::Related, 0.9),
3256 )
3257 .unwrap();
3258 associations
3259 .put(
3260 store.env(),
3261 &Association::new(b_id, a_id, LinkType::Related, 0.9),
3262 )
3263 .unwrap();
3264
3265 let tool = MemoryHybridRecallTool::as_search(store.clone(), None, None)
3266 .with_associations(Some(associations.clone()));
3267 let mut ctx = Context::default();
3268 let _ = &tool;
3272 let tantivy_dir = dir.path().join("tantivy");
3273 std::fs::create_dir_all(&tantivy_dir).unwrap();
3274 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3275 for (content, id) in [
3276 ("quarterly revenue planning notes", a_id),
3277 ("private salary bands", private.metadata.id),
3278 ("hiring plan for next quarter", b_id),
3279 ] {
3280 search
3281 .writer()
3282 .and_then(|mut w| {
3283 search.add_document(&mut w, &id.to_string(), "codex", content, &[], 0)?;
3284 search.commit(&mut w)
3285 })
3286 .unwrap();
3287 }
3288 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3289 .with_associations(Some(associations));
3290 let v = tool
3291 .call(&mut ctx, json!({"query": "quarterly revenue planning"}))
3292 .await
3293 .unwrap();
3294 let ids: Vec<&str> = v["results"]
3295 .as_array()
3296 .unwrap()
3297 .iter()
3298 .filter_map(|r| r["id"].as_str())
3299 .collect();
3300 assert!(
3301 !ids.iter().any(|id| *id == private.metadata.id.to_string()),
3302 "private memory must not surface via association: {ids:?}"
3303 );
3304 assert_eq!(
3305 ids.iter().filter(|id| **id == b_id.to_string()).count(),
3306 1,
3307 "neighbor linked both directions appears exactly once: {ids:?}"
3308 );
3309 }
3310
3311 #[tokio::test]
3312 async fn memory_update_content_recomputes_hash() {
3313 let store = test_store();
3314 let mem = Memory::new(Galaxy::Codex, "original text".into());
3315 store.put(Galaxy::Codex, &mem).unwrap();
3316 let id = mem.metadata.id;
3317 let original_hash = mem.metadata.content_hash.clone();
3318
3319 let tool = MemoryUpdateTool::new(store.clone(), None);
3320 let v = tool
3321 .call(
3322 &mut Context::default(),
3323 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3324 )
3325 .await
3326 .unwrap();
3327 assert_eq!(v["status"], "success");
3328
3329 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3332 assert_eq!(stored.content, "changed text");
3333 assert_eq!(
3334 stored.metadata.content_hash,
3335 wm_memory::content_hash("changed text")
3336 );
3337 assert_ne!(stored.metadata.content_hash, original_hash);
3338 }
3339
3340 #[tokio::test]
3341 async fn memory_update_discloses_hash_timeline() {
3342 let store = test_store();
3346 let mem = Memory::new(Galaxy::Codex, "original text".into());
3347 store.put(Galaxy::Codex, &mem).unwrap();
3348 let id = mem.metadata.id;
3349 let original_hash = mem.metadata.content_hash.clone();
3350 let tool = MemoryUpdateTool::new(store.clone(), None);
3351 let mut ctx = Context::default();
3352
3353 let v = tool
3354 .call(
3355 &mut ctx,
3356 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3357 )
3358 .await
3359 .unwrap();
3360 assert_eq!(
3361 v["content_hash"],
3362 json!(wm_memory::content_hash("changed text"))
3363 );
3364 assert_eq!(v["prev_content_hash"], json!(original_hash));
3365
3366 let v = tool
3368 .call(
3369 &mut ctx,
3370 json!({"galaxy": "codex", "id": id.to_string(), "tags": ["amended"]}),
3371 )
3372 .await
3373 .unwrap();
3374 assert_eq!(
3375 v["content_hash"],
3376 json!(wm_memory::content_hash("changed text"))
3377 );
3378 assert!(v.get("prev_content_hash").is_none());
3379 }
3380
3381 #[tokio::test]
3382 async fn memory_update_appends_revision_chain() {
3383 let store = test_store();
3386 let mem = Memory::new(Galaxy::Codex, "original text".into());
3387 store.put(Galaxy::Codex, &mem).unwrap();
3388 let id = mem.metadata.id;
3389 let tool = MemoryUpdateTool::new(store.clone(), None);
3390 let mut ctx = Context {
3391 user_id: Some("agent-b".to_string()),
3392 session_id: Some(uuid::Uuid::nil()),
3393 compartment: Some("production".to_string()),
3394 ..Default::default()
3395 };
3396 let v = tool
3397 .call(
3398 &mut ctx,
3399 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3400 )
3401 .await
3402 .unwrap();
3403 assert_eq!(v["revision"]["seq"], 0);
3404 assert_eq!(
3405 v["revision"]["old_hash"],
3406 json!(wm_memory::content_hash("original text"))
3407 );
3408 assert_eq!(
3409 v["revision"]["new_hash"],
3410 json!(wm_memory::content_hash("second text"))
3411 );
3412
3413 let v = tool
3414 .call(
3415 &mut ctx,
3416 json!({"galaxy": "codex", "id": id.to_string(), "content": "third text"}),
3417 )
3418 .await
3419 .unwrap();
3420 assert_eq!(v["revision"]["seq"], 1);
3421
3422 let revisions = store.revisions(Galaxy::Codex, id).unwrap();
3423 assert_eq!(revisions.len(), 2);
3424 assert_eq!(revisions[1].old_hash, revisions[0].new_hash, "chain links");
3425 assert_eq!(revisions[0].actor_user.as_deref(), Some("agent-b"));
3426 assert_eq!(
3427 revisions[0].actor_compartment.as_deref(),
3428 Some("production")
3429 );
3430 assert_eq!(
3431 revisions[0].actor_session.as_deref(),
3432 Some(uuid::Uuid::nil().to_string().as_str())
3433 );
3434
3435 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3436 assert_eq!(stored.metadata.revision_count, 2);
3437
3438 let report = store
3440 .verify_revision_chain(Galaxy::Codex, id, &stored.metadata.content_hash)
3441 .unwrap();
3442 assert!(report.valid, "{:?}", report.breaks);
3443 assert!(report.matches_head);
3444 }
3445
3446 #[tokio::test]
3447 async fn memory_update_out_of_band_edit_breaks_chain() {
3448 let store = test_store();
3451 let mem = Memory::new(Galaxy::Codex, "original text".into());
3452 store.put(Galaxy::Codex, &mem).unwrap();
3453 let id = mem.metadata.id;
3454 let tool = MemoryUpdateTool::new(store.clone(), None);
3455 tool.call(
3456 &mut Context::default(),
3457 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3458 )
3459 .await
3460 .unwrap();
3461
3462 let mut row = store.get(Galaxy::Codex, id).unwrap().unwrap();
3464 row.content = "smuggled text".to_string();
3465 row.metadata.content_hash = wm_memory::content_hash("smuggled text");
3466 store.put(Galaxy::Codex, &row).unwrap();
3467
3468 let report = store
3469 .verify_revision_chain(Galaxy::Codex, id, &row.metadata.content_hash)
3470 .unwrap();
3471 assert!(!report.valid);
3472 assert!(!report.matches_head);
3473 assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
3474 }
3475
3476 #[tokio::test]
3477 async fn memory_revisions_tool_list_and_verify() {
3478 let store = test_store();
3479 let mem = Memory::new(Galaxy::Codex, "v1".into());
3480 store.put(Galaxy::Codex, &mem).unwrap();
3481 let id = mem.metadata.id;
3482 let update = MemoryUpdateTool::new(store.clone(), None);
3483 update
3484 .call(
3485 &mut Context::default(),
3486 json!({"galaxy": "codex", "id": id.to_string(), "content": "v2"}),
3487 )
3488 .await
3489 .unwrap();
3490
3491 let tool = MemoryRevisionsTool::new(store.clone());
3492 let v = tool
3493 .call(&mut Context::default(), json!({"id": id.to_string()}))
3494 .await
3495 .unwrap();
3496 assert_eq!(v["action"], "list");
3497 assert_eq!(v["count"], 1);
3498
3499 let v = tool
3500 .call(
3501 &mut Context::default(),
3502 json!({"id": id.to_string(), "action": "verify"}),
3503 )
3504 .await
3505 .unwrap();
3506 assert_eq!(v["valid"], true);
3507 assert_eq!(v["entries"], 1);
3508
3509 store
3512 .record_revision(
3513 Galaxy::Codex,
3514 id,
3515 "forged_old_hash",
3516 &wm_memory::content_hash("v2"),
3517 wm_memory::RevisionActor::default(),
3518 )
3519 .unwrap();
3520 let v = tool
3521 .call(
3522 &mut Context::default(),
3523 json!({"id": id.to_string(), "action": "verify"}),
3524 )
3525 .await
3526 .unwrap();
3527 assert_eq!(v["valid"], false);
3528 let breaks: Vec<String> = v["breaks"]
3529 .as_array()
3530 .unwrap()
3531 .iter()
3532 .map(|b| b.as_str().unwrap().to_string())
3533 .collect();
3534 assert!(
3535 breaks.iter().any(|b| b.contains("hash-linkage")),
3536 "{breaks:?}"
3537 );
3538 }
3539
3540 #[tokio::test]
3541 async fn memory_update_applies_importance_verbatim() {
3542 let store = test_store();
3548
3549 let tel = Memory::new(
3550 Galaxy::Codex,
3551 "## Auto-logged Friction: dispatch error\n\nbody".into(),
3552 );
3553 store.put(Galaxy::Codex, &tel).unwrap();
3554
3555 let tool = MemoryUpdateTool::new(store.clone(), None);
3556 let mut ctx = Context::default();
3557
3558 let v = tool
3559 .call(
3560 &mut ctx,
3561 json!({"galaxy": "codex", "id": tel.metadata.id.to_string(), "importance": 0.9}),
3562 )
3563 .await
3564 .unwrap();
3565 assert!(v.get("class_policy").is_none());
3566 assert!(v.get("write_gate").is_none());
3567 let stored = store.get(Galaxy::Codex, tel.metadata.id).unwrap().unwrap();
3568 assert!((stored.metadata.importance - 0.9).abs() < 1e-5);
3569 }
3570
3571 #[tokio::test]
3572 async fn memory_search_min_trust_filter_drops_low_trust() {
3573 let (_dir, store, search) = hybrid_fixture();
3577 let mut confirmed = Memory::new(Galaxy::Codex, "Quantum foal registry minutes".into());
3578 confirmed.metadata.source_trust = 1.0;
3579 confirmed.metadata.source = "user".to_string();
3580 let mut ingested = Memory::new(Galaxy::Codex, "Quantum foal registry draft".into());
3581 ingested.metadata.source_trust = 0.7;
3582 ingested.metadata.source = "tool".to_string();
3583 store.put(Galaxy::Codex, &confirmed).unwrap();
3584 store.put(Galaxy::Codex, &ingested).unwrap();
3585 mirror_memory(&store, &confirmed, None, 1);
3586 mirror_memory(&store, &ingested, None, 2);
3587
3588 let tool = default_search_tool(store, Some(search));
3589 let mut ctx = Context::default();
3590
3591 let v = tool
3592 .call(
3593 &mut ctx,
3594 json!({"query": "quantum foal registry", "limit": 10}),
3595 )
3596 .await
3597 .unwrap();
3598 assert_eq!(v["count"], 2, "no floor: both results surface");
3599 assert!(v.get("min_trust").is_none());
3600
3601 let v = tool
3602 .call(
3603 &mut ctx,
3604 json!({"query": "quantum foal registry", "limit": 10, "min_trust": 0.9}),
3605 )
3606 .await
3607 .unwrap();
3608 assert_eq!(v["min_trust"], 0.9);
3609 assert_eq!(v["min_trust_filtered"], 1);
3610 let trusts: Vec<f64> = v["results"]
3611 .as_array()
3612 .unwrap()
3613 .iter()
3614 .map(|r| r["trust"].as_f64().unwrap())
3615 .collect();
3616 assert!(trusts.iter().all(|t| *t >= 0.9), "{trusts:?}");
3617 }
3618
3619 #[tokio::test]
3620 async fn memory_sort_by_importance_asc() {
3621 let store = test_store();
3622 populate_memories(&store, Galaxy::Codex);
3623 let tool = MemorySortTool::new(store);
3624 let mut ctx = Context::default();
3625 let v = tool
3626 .call(&mut ctx, json!({"sort_by": "importance", "order": "asc"}))
3627 .await
3628 .unwrap();
3629 let mems = v["memories"].as_array().unwrap();
3630 assert!(mems[0]["importance"].as_f64().unwrap() <= mems[1]["importance"].as_f64().unwrap());
3631 }
3632
3633 #[tokio::test]
3634 async fn memory_sort_by_recency() {
3635 let store = test_store();
3636 populate_memories(&store, Galaxy::Codex);
3637 let tool = MemorySortTool::new(store);
3638 let mut ctx = Context::default();
3639 let v = tool
3640 .call(&mut ctx, json!({"sort_by": "recency"}))
3641 .await
3642 .unwrap();
3643 assert_eq!(v["returned"], 3);
3644 }
3645
3646 #[tokio::test]
3647 async fn memory_sort_invalid_field() {
3648 let store = test_store();
3649 let tool = MemorySortTool::new(store);
3650 let mut ctx = Context::default();
3651 let result = tool.call(&mut ctx, json!({"sort_by": "invalid"})).await;
3652 assert!(result.is_err());
3653 }
3654
3655 #[tokio::test]
3656 async fn memory_sort_with_limit() {
3657 let store = test_store();
3658 populate_memories(&store, Galaxy::Codex);
3659 let tool = MemorySortTool::new(store);
3660 let mut ctx = Context::default();
3661 let v = tool.call(&mut ctx, json!({"limit": 2})).await.unwrap();
3662 assert_eq!(v["returned"], 2);
3663 assert_eq!(v["total"], 3);
3664 }
3665
3666 #[tokio::test]
3667 async fn memory_filter_by_tag() {
3668 let store = test_store();
3669 populate_memories(&store, Galaxy::Codex);
3670 let tool = MemoryFilterTool::new(store);
3671 let mut ctx = Context::default();
3672 let v = tool
3673 .call(&mut ctx, json!({"tags": ["rust"]}))
3674 .await
3675 .unwrap();
3676 assert_eq!(v["matched"], 2);
3677 }
3678
3679 #[tokio::test]
3680 async fn memory_filter_by_importance_range() {
3681 let store = test_store();
3682 populate_memories(&store, Galaxy::Codex);
3683 let tool = MemoryFilterTool::new(store);
3684 let mut ctx = Context::default();
3685 let v = tool
3686 .call(
3687 &mut ctx,
3688 json!({"min_importance": 0.4, "max_importance": 0.6}),
3689 )
3690 .await
3691 .unwrap();
3692 assert_eq!(v["matched"], 1);
3693 }
3694
3695 #[tokio::test]
3699 async fn memory_filter_query_matches_content_and_title() {
3700 let store = test_store();
3701 let tool = MemoryFilterTool::new(store.clone());
3702 let mut ctx = Context::default();
3703
3704 let mut titled = Memory::new(Galaxy::Codex, "unrelated body text".into());
3705 titled.metadata.title = Some("Rust Borrow Checker".into());
3706 let plain = Memory::new(Galaxy::Codex, "rust ownership rules".into());
3707 let other = Memory::new(Galaxy::Codex, "gardening tips".into());
3708 for m in [&titled, &plain, &other] {
3709 store.put(Galaxy::Codex, m).unwrap();
3710 }
3711
3712 let v = tool.call(&mut ctx, json!({"query": "RUST"})).await.unwrap();
3713 assert_eq!(v["matched"], 2, "content hit + title hit: {v}");
3714 assert_eq!(v["filters"]["query_terms"], json!(["rust"]));
3715
3716 let v = tool
3717 .call(&mut ctx, json!({"query": "rust borrow"}))
3718 .await
3719 .unwrap();
3720 assert_eq!(v["matched"], 1, "all terms must match: {v}");
3721 assert_eq!(v["memories"][0]["content"], "unrelated body text");
3722 }
3723
3724 #[tokio::test]
3725 async fn memory_filter_no_matches() {
3726 let store = test_store();
3727 populate_memories(&store, Galaxy::Codex);
3728 let tool = MemoryFilterTool::new(store);
3729 let mut ctx = Context::default();
3730 let v = tool
3731 .call(&mut ctx, json!({"tags": ["nonexistent"]}))
3732 .await
3733 .unwrap();
3734 assert_eq!(v["matched"], 0);
3735 }
3736
3737 #[tokio::test]
3738 async fn memory_filter_combined_tags_and_importance() {
3739 let store = test_store();
3740 populate_memories(&store, Galaxy::Codex);
3741 let tool = MemoryFilterTool::new(store);
3742 let mut ctx = Context::default();
3743 let v = tool
3744 .call(&mut ctx, json!({"tags": ["rust"], "min_importance": 0.5}))
3745 .await
3746 .unwrap();
3747 assert_eq!(v["matched"], 1);
3748 }
3749
3750 #[tokio::test]
3754 async fn memory_filter_offset_exclude_tags_and_date_range() {
3755 let store = test_store();
3756 let tool = MemoryFilterTool::new(store.clone());
3757 let mut ctx = Context::default();
3758
3759 let mut recent_a = Memory::new(Galaxy::Codex, "recent a".into());
3760 recent_a.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(2);
3761 let mut recent_b = Memory::new(Galaxy::Codex, "recent b".into());
3762 recent_b.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
3763 recent_b.metadata.tags = vec!["noise".into()];
3764 let mut recent_priv = Memory::new(Galaxy::Codex, "recent private".into());
3765 recent_priv.metadata.created_at = chrono::Utc::now() - chrono::Duration::minutes(90);
3766 recent_priv.metadata.is_private = true;
3767 let mut old = Memory::new(Galaxy::Codex, "old relic".into());
3768 old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
3769 for m in [&recent_a, &recent_b, &recent_priv, &old] {
3770 store.put(Galaxy::Codex, m).unwrap();
3771 }
3772
3773 let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
3774 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
3775
3776 let v = tool
3778 .call(
3779 &mut ctx,
3780 json!({
3781 "galaxy": "codex",
3782 "created_after": cutoff,
3783 "exclude_tags": ["noise"],
3784 }),
3785 )
3786 .await
3787 .unwrap();
3788 assert_eq!(v["matched"], 1, "only recent-a is visible in range: {v}");
3789 assert_eq!(v["returned"], 1);
3790 assert_eq!(v["memories"][0]["content"], "recent a");
3791 assert_eq!(v["filters"]["exclude_tags"], json!(["noise"]));
3792 assert!(v["filters"]["created_after"].is_string());
3793
3794 let page2 = tool
3797 .call(
3798 &mut ctx,
3799 json!({"galaxy": "codex", "offset": 3, "limit": 2}),
3800 )
3801 .await
3802 .unwrap();
3803 assert_eq!(
3804 page2["matched"], 3,
3805 "private memory must not count: {page2}"
3806 );
3807 assert_eq!(
3808 page2["returned"], 0,
3809 "offset past the match set is an honest empty page"
3810 );
3811 assert_eq!(page2["offset"], 3);
3812
3813 let bad = tool
3815 .call(
3816 &mut ctx,
3817 json!({"galaxy": "codex", "created_before": "yesterday"}),
3818 )
3819 .await;
3820 assert!(bad.is_err(), "non-RFC-3339 bound must be refused");
3821 }
3822
3823 #[tokio::test]
3824 async fn memory_deduplicate_hash_dry_run() {
3825 let store = test_store();
3826 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3827 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3828 let _ = store.put(Galaxy::Codex, &m1);
3829 let _ = store.put(Galaxy::Codex, &m2);
3830 let _ = store.put(
3831 Galaxy::Codex,
3832 &Memory::new(Galaxy::Codex, "unique content".into()),
3833 );
3834
3835 let tool = MemoryDeduplicateTool::new(store.clone(), None);
3836 let mut ctx = Context::default();
3837 let v = tool
3838 .call(&mut ctx, json!({"mode": "hash", "dry_run": true}))
3839 .await
3840 .unwrap();
3841 assert_eq!(v["duplicates_found"], 1);
3842 assert_eq!(v["removed"], 0);
3843
3844 let memories = store.scan(Galaxy::Codex, 100).unwrap();
3845 assert_eq!(memories.len(), 3);
3846 }
3847
3848 #[tokio::test]
3849 async fn memory_deduplicate_hash_execute() {
3850 let store = test_store();
3851 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3852 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3853 let _ = store.put(Galaxy::Codex, &m1);
3854 let _ = store.put(Galaxy::Codex, &m2);
3855 let _ = store.put(
3856 Galaxy::Codex,
3857 &Memory::new(Galaxy::Codex, "unique content".into()),
3858 );
3859
3860 let tool = MemoryDeduplicateTool::new(store.clone(), None);
3861 let mut ctx = Context::default();
3862 let v = tool
3863 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3864 .await
3865 .unwrap();
3866 assert_eq!(v["duplicates_found"], 1);
3867 assert_eq!(v["removed"], 1);
3868
3869 let memories = store.scan(Galaxy::Codex, 100).unwrap();
3870 assert_eq!(memories.len(), 2);
3871 }
3872
3873 #[tokio::test]
3874 async fn memory_deduplicate_deindexes_removed_memories() {
3875 let (_dir, store, search) = hybrid_fixture();
3878
3879 let m1 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3880 let m2 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3881 let id1 = m1.metadata.id;
3882 let id2 = m2.metadata.id;
3883 let _ = store.put(Galaxy::Codex, &m1);
3884 let _ = store.put(Galaxy::Codex, &m2);
3885 for mem in [&m1, &m2] {
3886 let mut writer = search.writer().unwrap();
3887 search
3888 .add_document(
3889 &mut writer,
3890 &mem.metadata.id.to_string(),
3891 mem.metadata.galaxy.db_name(),
3892 &mem.content,
3893 &mem.metadata.tags,
3894 mem.metadata.created_at.timestamp(),
3895 )
3896 .unwrap();
3897 search.commit(&mut writer).unwrap();
3898 }
3899
3900 let before = search.search_ids("index drift", 100).unwrap();
3902 assert_eq!(before.len(), 2);
3903
3904 let tool = MemoryDeduplicateTool::new(store.clone(), Some(search.clone()));
3905 let mut ctx = Context::default();
3906 let v = tool
3907 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3908 .await
3909 .unwrap();
3910 assert_eq!(v["removed"], 1);
3911
3912 let after = search.search_ids("index drift", 100).unwrap();
3915 assert_eq!(after.len(), 1, "search index should only contain survivors");
3916 assert!(
3917 after.contains(&id1) || after.contains(&id2),
3918 "survivor should be one of the original memories"
3919 );
3920 }
3921
3922 #[tokio::test]
3923 async fn memory_deduplicate_content_mode() {
3924 let store = test_store();
3925 let m1 = Memory::new(Galaxy::Codex, "same text".into());
3926 let m2 = Memory::new(Galaxy::Codex, "same text".into());
3927 let _ = store.put(Galaxy::Codex, &m1);
3928 let _ = store.put(Galaxy::Codex, &m2);
3929
3930 let tool = MemoryDeduplicateTool::new(store, None);
3931 let mut ctx = Context::default();
3932 let v = tool
3933 .call(&mut ctx, json!({"mode": "content", "dry_run": true}))
3934 .await
3935 .unwrap();
3936 assert_eq!(v["duplicates_found"], 1);
3937 }
3938
3939 #[tokio::test]
3940 async fn memory_deduplicate_no_duplicates() {
3941 let store = test_store();
3942 let _ = store.put(
3943 Galaxy::Codex,
3944 &Memory::new(Galaxy::Codex, "content a".into()),
3945 );
3946 let _ = store.put(
3947 Galaxy::Codex,
3948 &Memory::new(Galaxy::Codex, "content b".into()),
3949 );
3950
3951 let tool = MemoryDeduplicateTool::new(store, None);
3952 let mut ctx = Context::default();
3953 let v = tool.call(&mut ctx, json!({})).await.unwrap();
3954 assert_eq!(v["duplicates_found"], 0);
3955 }
3956
3957 #[tokio::test]
3958 async fn memory_deduplicate_invalid_mode() {
3959 let store = test_store();
3960 let tool = MemoryDeduplicateTool::new(store, None);
3961 let mut ctx = Context::default();
3962 let result = tool.call(&mut ctx, json!({"mode": "invalid"})).await;
3963 assert!(result.is_err());
3964 }
3965
3966 #[tokio::test]
3967 async fn memory_export_json() {
3968 let store = test_store();
3969 populate_memories(&store, Galaxy::Codex);
3970 let tool = MemoryExportTool::new(store);
3971 let mut ctx = Context::default();
3972 let v = tool
3973 .call(&mut ctx, json!({"format": "json"}))
3974 .await
3975 .unwrap();
3976 assert_eq!(v["format"], "json");
3977 assert_eq!(v["count"], 3);
3978 assert!(v["export"].as_str().unwrap().contains("First memory"));
3979 }
3980
3981 #[tokio::test]
3982 async fn memory_export_csv() {
3983 let store = test_store();
3984 populate_memories(&store, Galaxy::Codex);
3985 let tool = MemoryExportTool::new(store);
3986 let mut ctx = Context::default();
3987 let v = tool.call(&mut ctx, json!({"format": "csv"})).await.unwrap();
3988 let csv = v["export"].as_str().unwrap();
3989 assert!(csv.contains("id,content,tags"));
3990 assert!(csv.contains("First memory"));
3991 }
3992
3993 #[tokio::test]
3994 async fn memory_export_markdown() {
3995 let store = test_store();
3996 populate_memories(&store, Galaxy::Codex);
3997 let tool = MemoryExportTool::new(store);
3998 let mut ctx = Context::default();
3999 let v = tool
4000 .call(&mut ctx, json!({"format": "markdown"}))
4001 .await
4002 .unwrap();
4003 let md = v["export"].as_str().unwrap();
4004 assert!(md.contains("# Memory Export"));
4005 assert!(md.contains("First memory"));
4006 }
4007
4008 #[tokio::test]
4009 async fn memory_export_invalid_format() {
4010 let store = test_store();
4011 let tool = MemoryExportTool::new(store);
4012 let mut ctx = Context::default();
4013 let result = tool.call(&mut ctx, json!({"format": "xml"})).await;
4014 assert!(result.is_err());
4015 }
4016
4017 #[tokio::test]
4018 async fn memory_export_empty_galaxy() {
4019 let store = test_store();
4020 let tool = MemoryExportTool::new(store);
4021 let mut ctx = Context::default();
4022 let v = tool
4023 .call(&mut ctx, json!({"format": "json"}))
4024 .await
4025 .unwrap();
4026 assert_eq!(v["count"], 0);
4027 }
4028
4029 #[tokio::test]
4030 async fn memory_sort_and_filter_are_winnowing_basket_gana() {
4031 let store = test_store();
4032 assert_eq!(
4033 MemorySortTool::new(store.clone()).gana(),
4034 Gana::WinnowingBasket
4035 );
4036 assert_eq!(
4037 MemoryFilterTool::new(store.clone()).gana(),
4038 Gana::WinnowingBasket
4039 );
4040 assert_eq!(
4041 MemoryDeduplicateTool::new(store.clone(), None).gana(),
4042 Gana::WinnowingBasket
4043 );
4044 assert_eq!(MemoryExportTool::new(store).gana(), Gana::WinnowingBasket);
4045 }
4046
4047 #[test]
4056 fn hybrid_recall_routes_expose_query_schema() {
4057 let dir = tempfile::tempdir().unwrap();
4058 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4059 for tool in [
4060 MemoryHybridRecallTool::new(store.clone(), None, None),
4061 MemoryHybridRecallTool::as_search(store, None, None),
4062 ] {
4063 let schema = tool.input_schema();
4064 assert_eq!(schema["type"], "object");
4065 assert!(schema["properties"].get("query").is_some());
4066 assert_eq!(schema["required"], json!(["query"]));
4067 }
4068 }
4069
4070 fn hybrid_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4073 let dir = tempfile::tempdir().unwrap();
4074 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4075 let tantivy_dir = dir.path().join("tantivy");
4076 std::fs::create_dir_all(&tantivy_dir).unwrap();
4077 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
4078 (dir, store, search)
4079 }
4080
4081 fn index_memory(
4082 store: &Arc<MemoryStore>,
4083 search: &Arc<SearchEngine>,
4084 galaxy: Galaxy,
4085 content: &str,
4086 ) {
4087 let mem = Memory::new(galaxy, content.to_string());
4088 let id = mem.metadata.id;
4089 store.put(galaxy, &mem).unwrap();
4090 let mut writer = search.writer().unwrap();
4091 search
4092 .add_document(
4093 &mut writer,
4094 &id.to_string(),
4095 galaxy.db_name(),
4096 content,
4097 &mem.metadata.tags,
4098 mem.metadata.created_at.timestamp(),
4099 )
4100 .unwrap();
4101 search.commit(&mut writer).unwrap();
4102 }
4103
4104 #[tokio::test]
4105 async fn hybrid_recall_excludes_private_memories() {
4106 let (_dir, store, search) = hybrid_fixture();
4107
4108 let mut priv_mem = Memory::new(Galaxy::Codex, "private secret plan alpha".to_string());
4110 priv_mem.metadata.is_private = true;
4111 let id = priv_mem.metadata.id;
4112 store.put(Galaxy::Codex, &priv_mem).unwrap();
4113 {
4114 let mut writer = search.writer().unwrap();
4115 search
4116 .add_document(
4117 &mut writer,
4118 &id.to_string(),
4119 "codex",
4120 "private secret plan alpha",
4121 &[],
4122 priv_mem.metadata.created_at.timestamp(),
4123 )
4124 .unwrap();
4125 search.commit(&mut writer).unwrap();
4126 }
4127
4128 index_memory(
4130 &store,
4131 &search,
4132 Galaxy::Codex,
4133 "public plan alpha documentation",
4134 );
4135
4136 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4137 let v = tool
4138 .call(
4139 &mut Context::default(),
4140 json!({"query": "plan alpha", "galaxy": "codex"}),
4141 )
4142 .await
4143 .unwrap();
4144 let results = v["results"].as_array().unwrap();
4145 let contents: Vec<&str> = results
4146 .iter()
4147 .filter_map(|r| r["content"].as_str())
4148 .collect();
4149 assert!(
4150 !contents.iter().any(|c| c.contains("private")),
4151 "private memory leaked through hybrid recall: {results:?}"
4152 );
4153 assert!(
4154 contents.iter().any(|c| c.contains("public")),
4155 "public memory missing from hybrid recall: {results:?}"
4156 );
4157 }
4158
4159 #[tokio::test]
4160 async fn batch_read_treats_private_as_miss() {
4161 let store = test_store();
4162 let mut priv_mem = Memory::new(Galaxy::Codex, "private batch note".into());
4163 priv_mem.metadata.is_private = true;
4164 let priv_id = priv_mem.metadata.id;
4165 store.put(Galaxy::Codex, &priv_mem).unwrap();
4166 let pub_mem = Memory::new(Galaxy::Codex, "public batch note".into());
4167 let pub_id = pub_mem.metadata.id;
4168 store.put(Galaxy::Codex, &pub_mem).unwrap();
4169
4170 let tool = MemoryBatchReadTool::new(store);
4171 let v = tool
4172 .call(
4173 &mut Context::default(),
4174 json!({"galaxy": "codex", "ids": [priv_id.to_string(), pub_id.to_string()]}),
4175 )
4176 .await
4177 .unwrap();
4178 assert_eq!(v["found"], 1);
4179 assert_eq!(v["misses"], 1);
4180 assert!(
4181 !v["memories"]
4182 .as_array()
4183 .unwrap()
4184 .iter()
4185 .any(|m| m["content"].as_str().unwrap_or("").contains("private")),
4186 "private memory leaked through batch_read: {v}"
4187 );
4188 }
4189
4190 #[tokio::test]
4191 async fn hybrid_recall_incident_query_returns_only_relevant() {
4192 let (_dir, store, search) = hybrid_fixture();
4193 index_memory(
4194 &store,
4195 &search,
4196 Galaxy::Codex,
4197 "smoke test from wmClient: verify recall",
4198 );
4199 index_memory(
4200 &store,
4201 &search,
4202 Galaxy::Codex,
4203 "NES Evolution and Impact: a history of the console wars",
4204 );
4205 index_memory(
4206 &store,
4207 &search,
4208 Galaxy::Codex,
4209 "Insights on The Gateless Gate: koans and zen practice",
4210 );
4211 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4212 let mut ctx = Context::default();
4213 let v = tool
4214 .call(
4215 &mut ctx,
4216 json!({"query": "smoke test", "galaxy": "codex", "limit": 5}),
4217 )
4218 .await
4219 .unwrap();
4220 let results = v["results"].as_array().unwrap();
4221 assert_eq!(
4222 results.len(),
4223 1,
4224 "incident query must not return unrelated memories: {results:?}"
4225 );
4226 let hit = &results[0];
4227 assert_eq!(hit["source"], "fts");
4228 assert!(
4229 hit["content"]
4230 .as_str()
4231 .unwrap()
4232 .contains("smoke test from wmClient")
4233 );
4234 assert!(hit["normalized_score"].as_f64().unwrap() > 0.0);
4235 assert_eq!(v["count"], 1);
4236 }
4237
4238 #[tokio::test]
4239 async fn hybrid_recall_filters_stale_index_entries() {
4240 let (_dir, store, search) = hybrid_fixture();
4243 index_memory(
4244 &store,
4245 &search,
4246 Galaxy::Codex,
4247 "rust memory about ownership",
4248 );
4249 {
4250 let mut writer = search.writer().unwrap();
4251 search
4252 .add_document(
4253 &mut writer,
4254 "99999999-9999-9999-9999-999999999999",
4255 "codex",
4256 "rust ghost memory",
4257 &[],
4258 1700000000,
4259 )
4260 .unwrap();
4261 search.commit(&mut writer).unwrap();
4262 }
4263
4264 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4265 let mut ctx = Context::default();
4266 let v = tool
4267 .call(&mut ctx, json!({"query": "rust", "limit": 10}))
4268 .await
4269 .unwrap();
4270 let results = v["results"].as_array().unwrap();
4271 assert_eq!(results.len(), 1);
4272 assert_ne!(
4273 results[0]["id"].as_str().unwrap(),
4274 "99999999-9999-9999-9999-999999999999"
4275 );
4276 }
4277
4278 #[tokio::test]
4279 async fn hybrid_recall_respects_min_score_arg() {
4280 let (_dir, store, search) = hybrid_fixture();
4281 index_memory(&store, &search, Galaxy::Codex, "alpha");
4282 let filler = format!("alpha {}", "zzz ".repeat(400));
4283 index_memory(&store, &search, Galaxy::Codex, &filler);
4284
4285 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4287 let mut ctx = Context::default();
4288 let v = tool
4289 .call(&mut ctx, json!({"query": "alpha", "limit": 10}))
4290 .await
4291 .unwrap();
4292 assert_eq!(v["count"], 2);
4293
4294 let scores: Vec<f64> = v["results"]
4296 .as_array()
4297 .unwrap()
4298 .iter()
4299 .map(|r| r["score"].as_f64().unwrap())
4300 .collect();
4301 let lo = scores.iter().copied().fold(f64::MAX, f64::min);
4302 let hi = scores.iter().copied().fold(0.0, f64::max);
4303 let mid = f64::midpoint(hi, lo);
4304
4305 let v = tool
4306 .call(
4307 &mut ctx,
4308 json!({"query": "alpha", "limit": 10, "min_score": mid}),
4309 )
4310 .await
4311 .unwrap();
4312 assert_eq!(v["count"], 1);
4313 assert!((v["results"][0]["score"].as_f64().unwrap() - hi).abs() < 1e-3);
4314 }
4315
4316 #[tokio::test]
4317 async fn hybrid_recall_or_coverage_finds_partial_matches() {
4318 let (_dir, store, search) = hybrid_fixture();
4322 index_memory(&store, &search, Galaxy::Codex, "alpha only here");
4323 index_memory(&store, &search, Galaxy::Codex, "alpha beta gamma delta");
4324
4325 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4326 let mut ctx = Context::default();
4327 let v = tool
4328 .call(&mut ctx, json!({"query": "alpha beta gamma", "limit": 10}))
4329 .await
4330 .unwrap();
4331 let results = v["results"].as_array().unwrap();
4332 assert_eq!(results.len(), 1);
4335 assert!(
4336 results[0]["content"]
4337 .as_str()
4338 .unwrap()
4339 .contains("alpha beta gamma")
4340 );
4341
4342 let v = tool
4345 .call(
4346 &mut ctx,
4347 json!({"query": "alpha beta gamma zeta", "limit": 10}),
4348 )
4349 .await
4350 .unwrap();
4351 let results = v["results"].as_array().unwrap();
4352 assert_eq!(
4353 results.len(),
4354 1,
4355 "OR + coverage must require 2/4 token coverage: {results:?}"
4356 );
4357 assert!(
4358 results[0]["content"]
4359 .as_str()
4360 .unwrap()
4361 .contains("alpha beta gamma")
4362 );
4363 for r in results {
4364 assert!(
4365 matches!(r["source"].as_str(), Some("fts")),
4366 "results should be tagged fts"
4367 );
4368 }
4369 }
4370
4371 fn index_tagged_memory(
4372 store: &Arc<MemoryStore>,
4373 search: &Arc<SearchEngine>,
4374 galaxy: Galaxy,
4375 content: &str,
4376 tags: &[&str],
4377 ) {
4378 let mut mem = Memory::new(galaxy, content.to_string());
4379 mem.metadata.tags = tags.iter().map(ToString::to_string).collect();
4380 let id = mem.metadata.id;
4381 store.put(galaxy, &mem).unwrap();
4382 let mut writer = search.writer().unwrap();
4383 search
4384 .add_document(
4385 &mut writer,
4386 &id.to_string(),
4387 galaxy.db_name(),
4388 content,
4389 &mem.metadata.tags,
4390 mem.metadata.created_at.timestamp(),
4391 )
4392 .unwrap();
4393 search.commit(&mut writer).unwrap();
4394 }
4395
4396 fn aggregate_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4397 let (dir, store, search) = hybrid_fixture();
4398 index_tagged_memory(
4400 &store,
4401 &search,
4402 Galaxy::Codex,
4403 "I started learning Rust.",
4404 &["user", "session_002"],
4405 );
4406 index_tagged_memory(
4407 &store,
4408 &search,
4409 Galaxy::Codex,
4410 "I finished my first Rust project, a CLI tool.",
4411 &["user", "session_007"],
4412 );
4413 index_tagged_memory(
4414 &store,
4415 &search,
4416 Galaxy::Codex,
4417 "I got a job as a systems engineer using Rust.",
4418 &["user", "session_012"],
4419 );
4420 index_tagged_memory(
4423 &store,
4424 &search,
4425 Galaxy::Codex,
4426 "I started learning Go.",
4427 &["user", "session_003"],
4428 );
4429 index_tagged_memory(
4430 &store,
4431 &search,
4432 Galaxy::Codex,
4433 "I got a job as a backend engineer using Go.",
4434 &["user", "session_015"],
4435 );
4436 (dir, store, search)
4437 }
4438
4439 #[tokio::test]
4440 async fn aggregate_session_span_isolated_by_rarest_term() {
4441 let (_dir, store, search) = aggregate_fixture();
4442 let tool = MemoryAggregateTool::new(Some(search), store);
4443 let mut ctx = Context::default();
4444 let v = tool
4445 .call(
4446 &mut ctx,
4447 json!({
4448 "query": "How long did it take from starting Rust to getting a job using it?",
4449 "metric": "session_span",
4450 }),
4451 )
4452 .await
4453 .unwrap();
4454 assert_eq!(v["aggregate"]["value"], 10, "session_012 - session_002");
4455 assert_eq!(v["aggregate"]["unit"], "sessions");
4456 assert_eq!(v["aggregate"]["content"], "10 sessions");
4457 }
4458
4459 #[tokio::test]
4460 async fn aggregate_session_count() {
4461 let (_dir, store, search) = aggregate_fixture();
4462 let tool = MemoryAggregateTool::new(Some(search), store);
4463 let mut ctx = Context::default();
4464 let v = tool
4465 .call(
4466 &mut ctx,
4467 json!({
4468 "query": "How long did it take from starting Rust to getting a job using it?",
4469 "metric": "session_count",
4470 }),
4471 )
4472 .await
4473 .unwrap();
4474 assert_eq!(v["aggregate"]["value"], 2);
4480 }
4481
4482 #[tokio::test]
4483 async fn aggregate_count_needs_no_session_tags() {
4484 let (_dir, store, search) = aggregate_fixture();
4485 let tool = MemoryAggregateTool::new(Some(search), store);
4486 let mut ctx = Context::default();
4487 let v = tool
4488 .call(
4489 &mut ctx,
4490 json!({"query": "Rust project", "metric": "count"}),
4491 )
4492 .await
4493 .unwrap();
4494 assert_eq!(v["aggregate"]["value"], 3);
4496 }
4497
4498 #[tokio::test]
4502 async fn aggregate_single_session_falls_back_honestly() {
4503 let (_dir, store, search) = aggregate_fixture();
4504 let tool = MemoryAggregateTool::new(Some(search), store);
4505 let mut ctx = Context::default();
4506 let v = tool
4508 .call(
4509 &mut ctx,
4510 json!({"query": "CLI tool", "metric": "session_count"}),
4511 )
4512 .await
4513 .unwrap();
4514 assert_eq!(v["aggregate"]["value"], 1, "one session in evidence: {v}");
4515 assert_eq!(v["anchor"], "session_tagged_fallback");
4516 let v = tool
4517 .call(
4518 &mut ctx,
4519 json!({"query": "CLI tool", "metric": "session_span"}),
4520 )
4521 .await
4522 .unwrap();
4523 assert_eq!(v["aggregate"]["value"], 0, "single point spans 0: {v}");
4524 }
4525
4526 #[tokio::test]
4527 async fn aggregate_rejects_unknown_metric() {
4528 let (_dir, store, search) = aggregate_fixture();
4529 let tool = MemoryAggregateTool::new(Some(search), store);
4530 let mut ctx = Context::default();
4531 let err = tool
4532 .call(&mut ctx, json!({"query": "x", "metric": "median"}))
4533 .await
4534 .unwrap_err();
4535 assert!(err.to_string().contains("unknown metric"));
4536 }
4537}