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, galaxy_search_arg, int_prop, num_prop, parse_galaxy, parse_galaxy_or,
18 schema, str_prop,
19};
20
21fn fused_mode_label(vector_weight: f32) -> &'static str {
28 if vector_weight > 0.0 {
29 "hybrid"
30 } else {
31 "bm25"
32 }
33}
34
35fn with_navigation_disclosure(mut result: serde_json::Value, original: &str) -> serde_json::Value {
38 let limit = wm_memory::search::MAX_INDEX_CONTENT_LEN;
39 let scrubbed = result
40 .get("content")
41 .and_then(serde_json::Value::as_str)
42 .is_none_or(|navigation| navigation != original);
43 if let Some(obj) = result.as_object_mut() {
44 obj.insert(
45 "content_representation".into(),
46 json!("scrubbed_navigation"),
47 );
48 obj.insert("content_character_limit".into(), json!(limit));
49 obj.insert(
50 "content_truncated".into(),
51 json!(original.chars().nth(limit).is_some()),
52 );
53 obj.insert("content_scrubbed".into(), json!(scrubbed));
54 obj.insert("exact_read_available".into(), json!(true));
55 if let Some(pattern) = wm_memory::detect_injection(original) {
60 obj.insert("instruction_shaped".into(), json!(true));
61 obj.insert("instruction_pattern".into(), json!(pattern));
62 }
63 }
64 result
65}
66
67fn abstention_floor() -> f32 {
69 std::env::var("WM_RECALL_ABSTENTION_FLOOR")
70 .ok()
71 .and_then(|value| value.trim().parse::<f32>().ok())
72 .filter(|value| *value > 0.0)
73 .unwrap_or(0.0)
74}
75
76fn abstention_coverage() -> f32 {
78 std::env::var("WM_RECALL_ABSTENTION_COVERAGE")
79 .ok()
80 .and_then(|value| value.trim().parse::<f32>().ok())
81 .filter(|value| (0.0..=1.0).contains(value) && *value > 0.0)
82 .unwrap_or(0.0)
83}
84
85fn weak_evidence_abstention(
93 results: &[serde_json::Value],
94 query: &str,
95) -> Option<serde_json::Value> {
96 weak_evidence_abstention_with(results, query, abstention_floor(), abstention_coverage())
97}
98
99fn weak_evidence_abstention_with(
101 results: &[serde_json::Value],
102 query: &str,
103 floor: f32,
104 coverage_floor: f32,
105) -> Option<serde_json::Value> {
106 if floor <= 0.0 && coverage_floor <= 0.0 {
107 return None;
108 }
109 let top = results.first()?;
110 let source = top
111 .get("source")
112 .and_then(serde_json::Value::as_str)
113 .unwrap_or("");
114 match source {
115 "hybrid" | "bm25" | "fts" => {
116 let raw = top
117 .get("raw_score")
118 .and_then(serde_json::Value::as_f64)
119 .unwrap_or(0.0);
120 if floor > 0.0 && raw > 0.0 && (raw as f32) < floor {
123 return Some(json!({
124 "status": "insufficient_evidence",
125 "reason": "top_below_floor",
126 "scope": "retrieval",
127 "signal": "bm25",
128 "top_score": raw,
129 "floor": floor,
130 }));
131 }
132 if source == "fts" && coverage_floor > 0.0 {
137 if let Some(content) = top.get("content").and_then(serde_json::Value::as_str) {
138 if !content.is_empty() {
139 let coverage = wm_memory::token_coverage(content, query);
140 if coverage < f64::from(coverage_floor) {
141 return Some(json!({
142 "status": "insufficient_evidence",
143 "reason": "coverage_below_floor",
144 "scope": "retrieval",
145 "signal": "coverage",
146 "coverage": coverage,
147 "floor": coverage_floor,
148 }));
149 }
150 }
151 }
152 }
153 }
154 "episodic" => {
155 let matched = top
156 .get("matched_terms")
157 .and_then(serde_json::Value::as_u64)
158 .unwrap_or(0);
159 let query_terms = wm_memory::strip_stopwords(query).split_whitespace().count() as u64;
160 if coverage_floor > 0.0 && query_terms > 0 {
161 let coverage = matched as f64 / query_terms as f64;
162 if coverage < f64::from(coverage_floor) {
163 return Some(json!({
164 "status": "insufficient_evidence",
165 "reason": "coverage_below_floor",
166 "scope": "retrieval",
167 "signal": "coverage",
168 "coverage": coverage,
169 "matched_terms": matched,
170 "query_terms": query_terms,
171 "floor": coverage_floor,
172 }));
173 }
174 }
175 }
176 _ => {}
177 }
178 None
179}
180
181fn build_evidence_bundle(store: &MemoryStore, results: &[serde_json::Value]) -> serde_json::Value {
185 let entries: Vec<serde_json::Value> = results
186 .iter()
187 .map(|r| {
188 let id = r
189 .get("id")
190 .and_then(serde_json::Value::as_str)
191 .unwrap_or_default()
192 .to_string();
193 let galaxy = r
194 .get("galaxy")
195 .and_then(serde_json::Value::as_str)
196 .unwrap_or_default()
197 .to_string();
198 let resolved = uuid::Uuid::parse_str(&id)
199 .ok()
200 .and_then(|key| resolve_memory_across_galaxies(store, key));
201 let (source_time, visibility, history) = match &resolved {
202 Some((_, mem)) => {
203 let revisions = store
204 .revisions(mem.metadata.galaxy, mem.metadata.id)
205 .unwrap_or_default();
206 let chain_valid =
207 wm_memory::revision::verify_chain(&revisions, &mem.metadata.content_hash)
208 .valid;
209 (
210 json!({
211 "created_at": mem.metadata.created_at,
212 "basis": "recorded_at",
213 "event_time": serde_json::Value::Null,
214 "event_time_basis": "not_tracked",
215 }),
216 json!({
217 "private": mem.metadata.is_private,
218 "model_exclude": mem.metadata.model_exclude,
219 }),
220 json!({
221 "revision_count": revisions.len(),
222 "superseded": !revisions.is_empty(),
223 "chain_valid": chain_valid,
224 "current": true,
225 }),
226 )
227 }
228 None => (
229 json!({
230 "created_at": serde_json::Value::Null,
231 "basis": "unavailable_cold_record",
232 "event_time": serde_json::Value::Null,
233 "event_time_basis": "not_tracked",
234 }),
235 json!({
236 "private": false,
237 "model_exclude": !r
238 .get("model_visible")
239 .and_then(serde_json::Value::as_bool)
240 .unwrap_or(false),
241 }),
242 json!({
243 "revision_count": 0,
244 "superseded": false,
245 "chain_valid": serde_json::Value::Null,
246 "current": true,
247 "basis": "unavailable_cold_record",
248 }),
249 ),
250 };
251 let mut retrieval = json!({
252 "route": r.get("source").cloned().unwrap_or(serde_json::Value::Null),
253 "score": r.get("score").cloned().unwrap_or(serde_json::Value::Null),
254 });
255 if let Some(terms) = r.get("matched_terms") {
256 retrieval["matched_terms"] = terms.clone();
257 }
258 if let Some(via) = r.get("via") {
259 retrieval["via"] = via.clone();
260 }
261 json!({
262 "id": id,
263 "galaxy": galaxy,
264 "retrieval": retrieval,
265 "source_time": source_time,
266 "history": history,
267 "integrity": r
268 .get("integrity")
269 .cloned()
270 .unwrap_or_else(|| json!("source_read")),
271 "visibility": visibility,
272 "coverage": {
273 "representation": r
274 .get("content_representation")
275 .cloned()
276 .unwrap_or(serde_json::Value::Null),
277 "truncated": r
278 .get("content_truncated")
279 .cloned()
280 .unwrap_or(serde_json::Value::Null),
281 "exact_read_available": r
282 .get("exact_read_available")
283 .cloned()
284 .unwrap_or(json!(false)),
285 },
286 })
287 })
288 .collect();
289 let shims: Vec<wm_memory::episodic::EpisodicSearchResult> = results
290 .iter()
291 .filter_map(|r| {
292 let id = r.get("id").and_then(serde_json::Value::as_str)?;
293 let key = uuid::Uuid::parse_str(id).ok()?;
294 let record = store.episodic().get(key).ok().flatten()?;
295 Some(wm_memory::episodic::EpisodicSearchResult {
296 record,
297 score: 0.0,
298 matched_terms: 0,
299 })
300 })
301 .collect();
302 let conflicts = detect_conflicts(&shims);
303 let pairs: Vec<serde_json::Value> = conflicts
304 .iter()
305 .map(|c| {
306 json!({
307 "later": c.later_record.to_string(),
308 "earlier": c.earlier_record.to_string(),
309 "marker": c.marker,
310 "shared_terms": c.shared_terms,
311 })
312 })
313 .collect();
314 json!({
315 "version": "v0",
316 "count": entries.len(),
317 "entries": entries,
318 "conflicts": {"count": pairs.len(), "pairs": pairs},
319 })
320}
321
322fn resolve_memory_across_galaxies(
325 store: &MemoryStore,
326 id: uuid::Uuid,
327) -> Option<(wm_core::Galaxy, wm_memory::Memory)> {
328 for galaxy in wm_core::Galaxy::memory_galaxies() {
329 if let Ok(Some(mem)) = store.get(galaxy, id) {
330 return Some((galaxy, mem));
331 }
332 }
333 None
334}
335
336pub struct MemoryConsolidateTool {
338 store: Arc<MemoryStore>,
339 search: Option<Arc<SearchEngine>>,
340 stats: ToolStats,
341 effects: EffectRow,
342}
343
344impl MemoryConsolidateTool {
345 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
346 Self {
347 store,
348 search,
349 stats: ToolStats::default(),
350 effects: EffectRow {
351 writes: super::common::memory_galaxy_writes(),
352 reads: super::common::memory_galaxy_reads(),
353 destructive: true,
354 ..Default::default()
355 },
356 }
357 }
358}
359
360#[async_trait]
361impl Tool for MemoryConsolidateTool {
362 fn input_schema(&self) -> Value {
363 schema(
364 &json!({
365 "galaxy": super::common::str_prop("Galaxy to consolidate (optional; default codex)"),
366 }),
367 &[],
368 )
369 }
370 fn name(&self) -> &str {
371 "memory.consolidate"
372 }
373 fn gana(&self) -> Gana {
374 Gana::Encampment
375 }
376 fn effects(&self) -> &EffectRow {
377 &self.effects
378 }
379 fn description(&self) -> &str {
380 "Deduplicate memories by content_hash within a galaxy"
381 }
382 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
383 let galaxy = args
384 .get("galaxy")
385 .and_then(|v| v.as_str())
386 .unwrap_or("codex");
387 let galaxy = parse_galaxy(galaxy)?;
388 let memories = self.store.scan_all(galaxy)?;
391 let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
392 let mut duplicates = 0u32;
393 for mem in &memories {
394 let hash = &mem.metadata.content_hash;
395 if let Some(existing_id) = seen_hashes.get(hash) {
396 if *existing_id != mem.metadata.id {
397 self.store.delete(galaxy, mem.metadata.id)?;
398 super::common::deindex(self.search.as_deref(), &mem.metadata.id.to_string());
399 duplicates += 1;
400 }
401 } else {
402 seen_hashes.insert(hash.clone(), mem.metadata.id);
403 }
404 }
405 Ok(json!({
406 "status": "success",
407 "galaxy": galaxy_name(galaxy),
408 "scanned": memories.len(),
409 "duplicates_removed": duplicates,
410 }))
411 }
412 fn stats(&self) -> &ToolStats {
413 &self.stats
414 }
415}
416
417pub struct MemoryDecayTool {
419 store: Arc<MemoryStore>,
420 stats: ToolStats,
421 effects: EffectRow,
422}
423
424impl MemoryDecayTool {
425 pub fn new(store: Arc<MemoryStore>) -> Self {
426 Self {
427 store,
428 stats: ToolStats::default(),
429 effects: EffectRow {
430 writes: super::common::memory_galaxy_writes(),
431 reads: super::common::memory_galaxy_reads(),
432 ..Default::default()
433 },
434 }
435 }
436}
437
438#[async_trait]
439impl Tool for MemoryDecayTool {
440 fn input_schema(&self) -> Value {
441 schema(
442 &json!({
443 "galaxy": super::common::str_prop("Galaxy to decay (optional; default codex)"),
444 "importance_threshold": super::common::num_prop("Decay memories below this importance (0-1)"),
445 "decay_factor": super::common::num_prop("Multiplier applied to importance (0-1)"),
446 }),
447 &[],
448 )
449 }
450 fn name(&self) -> &str {
451 "memory.decay"
452 }
453 fn gana(&self) -> Gana {
454 Gana::WinnowingBasket
455 }
456 fn effects(&self) -> &EffectRow {
457 &self.effects
458 }
459 fn description(&self) -> &str {
460 "Lower importance of old, low-access memories (never deletes)"
461 }
462 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
463 let galaxy = args
464 .get("galaxy")
465 .and_then(|v| v.as_str())
466 .unwrap_or("codex");
467 let galaxy = parse_galaxy(galaxy)?;
468 let threshold = args
469 .get("importance_threshold")
470 .and_then(serde_json::Value::as_f64)
471 .unwrap_or(0.3) as f32;
472 let decay_factor = args
473 .get("decay_factor")
474 .and_then(serde_json::Value::as_f64)
475 .unwrap_or(0.9) as f32;
476 let memories = self.store.scan(galaxy, 10_000)?;
477 let mut decayed = 0u32;
478 for mem in &memories {
479 if mem.metadata.importance < threshold {
480 let mut updated = mem.clone();
481 updated.metadata.importance =
482 (updated.metadata.importance * decay_factor).clamp(0.0, 1.0);
483 if (updated.metadata.importance - mem.metadata.importance).abs() > 0.001 {
484 self.store.put(galaxy, &updated)?;
485 decayed += 1;
486 }
487 }
488 }
489 Ok(json!({
490 "status": "success",
491 "galaxy": galaxy_name(galaxy),
492 "scanned": memories.len(),
493 "decayed": decayed,
494 }))
495 }
496 fn stats(&self) -> &ToolStats {
497 &self.stats
498 }
499}
500
501pub struct MemoryBatchReadTool {
503 store: Arc<MemoryStore>,
504 stats: ToolStats,
505 effects: EffectRow,
506}
507
508impl MemoryBatchReadTool {
509 pub fn new(store: Arc<MemoryStore>) -> Self {
510 Self {
511 store,
512 stats: ToolStats::default(),
513 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
514 }
515 }
516}
517
518#[async_trait]
519impl Tool for MemoryBatchReadTool {
520 fn name(&self) -> &str {
521 "memory.batch_read"
522 }
523 fn gana(&self) -> Gana {
524 Gana::WinnowingBasket
525 }
526 fn effects(&self) -> &EffectRow {
527 &self.effects
528 }
529 fn description(&self) -> &str {
530 "Read multiple memories by ID from a galaxy"
531 }
532 fn input_schema(&self) -> Value {
533 super::common::schema(
534 &json!({
535 "ids": super::common::str_array_prop("Memory UUIDs to read"),
536 "galaxy": super::common::str_prop("Galaxy (default: codex)"),
537 }),
538 &["ids"],
539 )
540 }
541 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
542 let galaxy = args
543 .get("galaxy")
544 .and_then(|v| v.as_str())
545 .unwrap_or("codex");
546 let galaxy = parse_galaxy(galaxy)?;
547 let ids = args
548 .get("ids")
549 .and_then(|v| v.as_array())
550 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'ids' array".into()))?;
551 let mut results = Vec::new();
552 let mut misses = 0u32;
553 for id_val in ids {
554 if let Some(id_str) = id_val.as_str() {
555 if let Ok(id) = uuid::Uuid::parse_str(id_str) {
556 match self.store.get(galaxy, id)? {
557 Some(mem)
558 if crate::expansion::common::mcp_visible(&mem)
559 && crate::expansion::common::validity_visible(&mem) =>
560 {
561 results.push(json!({
562 "id": mem.metadata.id,
563 "content": mem.content,
564 "tags": mem.metadata.tags,
565 "importance": mem.metadata.importance,
566 }));
567 }
568 Some(_) => {
571 misses += 1;
572 }
573 None => {
574 misses += 1;
575 }
576 }
577 }
578 }
579 }
580 Ok(json!({
581 "status": "success",
582 "galaxy": galaxy_name(galaxy),
583 "found": results.len(),
584 "misses": misses,
585 "memories": results,
586 }))
587 }
588 fn stats(&self) -> &ToolStats {
589 &self.stats
590 }
591}
592
593pub struct MemoryUpdateTool {
598 store: Arc<MemoryStore>,
599 search: Option<Arc<SearchEngine>>,
600 stats: ToolStats,
601 effects: EffectRow,
602}
603
604impl MemoryUpdateTool {
605 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
606 Self {
607 store,
608 search,
609 stats: ToolStats::default(),
610 effects: EffectRow {
611 writes: super::common::memory_galaxy_writes(),
612 reads: super::common::memory_galaxy_reads(),
613 sandbox: wm_core::Sandbox::StoreScoped,
615 ..Default::default()
616 },
617 }
618 }
619}
620
621#[async_trait]
622impl Tool for MemoryUpdateTool {
623 fn name(&self) -> &str {
624 "memory.update"
625 }
626 fn gana(&self) -> Gana {
627 Gana::Encampment
628 }
629 fn effects(&self) -> &EffectRow {
630 &self.effects
631 }
632 fn description(&self) -> &str {
633 "Update tags, importance, title/topic, or content of an existing memory"
634 }
635 fn input_schema(&self) -> Value {
636 super::common::schema(
637 &json!({
638 "id": super::common::str_prop("Memory UUID to update"),
639 "content": super::common::str_prop("New content (optional)"),
640 "tags": super::common::str_array_prop("Replacement tags (optional)"),
641 "importance": super::common::bounded_num_prop("New importance 0.0-1.0 (optional)", 0.0, 1.0),
642 "title": super::common::str_prop("New title (optional)"),
643 "topic": super::common::str_prop("New topic label (optional)"),
644 "galaxy": super::common::str_prop("Galaxy (default: codex)"),
645 }),
646 &["id"],
647 )
648 }
649 async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
650 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
651 let id_str = args
652 .get("id")
653 .and_then(|v| v.as_str())
654 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
655 let id = uuid::Uuid::parse_str(id_str)
656 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
657 if let Some(search) = &self.search {
658 if search.is_readonly() {
659 return Err(wm_core::CoreError::InvalidArgs(
660 "read-only mode: memory.update disabled (another process owns the index)"
661 .into(),
662 ));
663 }
664 }
665 let mut mem = self.store.get(galaxy, id)?.ok_or_else(|| {
666 wm_core::CoreError::NotFound(format!(
667 "Memory {id} not found in {}",
668 galaxy_name(galaxy)
669 ))
670 })?;
671 let previous_hash = mem.metadata.content_hash.clone();
672 let content_changed = args.get("content").and_then(|v| v.as_str()).is_some();
673 if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
674 mem.metadata.tags = tags
675 .iter()
676 .filter_map(|t| t.as_str().map(String::from))
677 .collect();
678 }
679 if args.get("importance").is_some() && !args["importance"].is_null() {
685 let importance =
686 wm_dispatch::write_gate::parse_importance_value(args.get("importance"))
687 .map_err(wm_core::CoreError::InvalidArgs)?;
688 if let Some(importance) = importance {
689 mem.metadata.importance = importance;
690 }
691 }
692 if let Some(title) = args.get("title") {
695 mem.metadata.title = title
696 .as_str()
697 .map(str::trim)
698 .filter(|s| !s.is_empty())
699 .map(String::from);
700 }
701 if let Some(topic) = args.get("topic") {
702 mem.metadata.topic = topic
703 .as_str()
704 .map(str::trim)
705 .filter(|s| !s.is_empty())
706 .map(String::from);
707 }
708 if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
709 mem.content = content.to_string();
710 mem.metadata.content_hash = wm_memory::content_hash(content);
713 mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
716 }
717 self.store.put(galaxy, &mem)?;
718
719 let mut revision_disclosure = None;
723 if content_changed {
724 let actor = wm_memory::RevisionActor {
725 session: ctx.session_id.map(|sid| sid.to_string()),
726 user: ctx.user_id.clone(),
727 compartment: ctx.compartment.clone(),
728 };
729 match self.store.record_revision(
730 galaxy,
731 id,
732 &previous_hash,
733 &mem.metadata.content_hash,
734 actor,
735 ) {
736 Ok(rev) => {
737 revision_disclosure = Some(serde_json::json!({
738 "seq": rev.seq,
739 "old_hash": rev.old_hash,
740 "new_hash": rev.new_hash,
741 }));
742 }
743 Err(e) => {
744 tracing::warn!(error = %e, "revision chain record failed for {id_str}");
745 revision_disclosure = Some(serde_json::json!({"record_failed": e.to_string()}));
746 }
747 }
748 }
749
750 let cred_kinds = args
753 .get("content")
754 .and_then(serde_json::Value::as_str)
755 .map(wm_memory::credential_shaped_content)
756 .unwrap_or_default();
757
758 let instruction_pattern = wm_memory::detect_injection(&mem.content);
761
762 super::common::replace_memory_index(&self.store, self.search.as_deref(), &mem);
765
766 let mut response = json!({
767 "status": "success",
768 "id": mem.metadata.id,
769 "galaxy": galaxy_name(galaxy),
770 "tags": mem.metadata.tags,
771 "importance": mem.metadata.importance,
772 "content_hash": mem.metadata.content_hash,
777 });
778 if content_changed {
779 response["prev_content_hash"] = json!(previous_hash);
780 }
781 if let Some(rev) = revision_disclosure {
782 response["revision"] = rev;
783 }
784 if !cred_kinds.is_empty() || instruction_pattern.is_some() {
785 let mut warnings: Vec<String> = cred_kinds
786 .iter()
787 .map(|k| {
788 format!(
789 "content looks like a credential ({k}) — {}",
790 wm_memory::CREDENTIAL_ADVICE
791 )
792 })
793 .collect();
794 if let Some(pattern) = instruction_pattern {
795 warnings.push(format!(
796 "content contains an instruction-shaped pattern ({pattern}) — stored as data; \
797 review it before trusting it as context"
798 ));
799 }
800 response["warnings"] = json!(warnings);
801 }
802 Ok(response)
803 }
804 fn stats(&self) -> &ToolStats {
805 &self.stats
806 }
807}
808
809pub struct MemoryRevisionsTool {
816 store: Arc<MemoryStore>,
817 stats: ToolStats,
818 effects: EffectRow,
819}
820
821impl MemoryRevisionsTool {
822 pub fn new(store: Arc<MemoryStore>) -> Self {
823 Self {
824 store,
825 stats: ToolStats::default(),
826 effects: EffectRow {
827 reads: super::common::memory_galaxy_reads(),
828 ..Default::default()
829 },
830 }
831 }
832}
833
834#[async_trait]
835impl Tool for MemoryRevisionsTool {
836 fn name(&self) -> &str {
837 "memory.revisions"
838 }
839 fn gana(&self) -> Gana {
840 Gana::WinnowingBasket
841 }
842 fn effects(&self) -> &EffectRow {
843 &self.effects
844 }
845 fn description(&self) -> &str {
846 "List or verify a memory's content revision chain (tamper evidence)"
847 }
848 fn input_schema(&self) -> Value {
849 super::common::schema(
850 &json!({
851 "id": super::common::str_prop("Memory UUID to inspect"),
852 "action": super::common::str_prop("Action: list (default) | verify"),
853 "galaxy": super::common::str_prop("Galaxy (default: codex)"),
854 }),
855 &["id"],
856 )
857 }
858 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
859 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
860 let id_str = args
861 .get("id")
862 .and_then(|v| v.as_str())
863 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
864 let id = uuid::Uuid::parse_str(id_str)
865 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
866 let action = args
867 .get("action")
868 .and_then(|v| v.as_str())
869 .unwrap_or("list");
870 let revisions = self.store.revisions(galaxy, id)?;
871 match action {
872 "verify" => {
873 let mem = self.store.get(galaxy, id)?.ok_or_else(|| {
874 wm_core::CoreError::NotFound(format!(
875 "Memory {id} not found in {}",
876 galaxy_name(galaxy)
877 ))
878 })?;
879 let report =
880 self.store
881 .verify_revision_chain(galaxy, id, &mem.metadata.content_hash)?;
882 Ok(json!({
883 "status": "success",
884 "id": id,
885 "galaxy": galaxy_name(galaxy),
886 "action": "verify",
887 "valid": report.valid,
888 "entries": report.entries,
889 "matches_head": report.matches_head,
890 "breaks": report.breaks,
891 "note": if report.entries == 0 {
892 "no revisions recorded (never content-updated or pre-S11c)"
893 } else { "" },
894 }))
895 }
896 "list" => Ok(json!({
897 "status": "success",
898 "id": id,
899 "galaxy": galaxy_name(galaxy),
900 "action": "list",
901 "count": revisions.len(),
902 "revisions": revisions,
903 })),
904 other => Err(wm_core::CoreError::InvalidArgs(format!(
905 "Unknown action '{other}' (expected 'list' or 'verify')"
906 ))),
907 }
908 }
909 fn stats(&self) -> &ToolStats {
910 &self.stats
911 }
912}
913
914pub struct MemoryTagTool {
916 store: Arc<MemoryStore>,
917 stats: ToolStats,
918 effects: EffectRow,
919}
920
921impl MemoryTagTool {
922 pub fn new(store: Arc<MemoryStore>) -> Self {
923 Self {
924 store,
925 stats: ToolStats::default(),
926 effects: EffectRow {
927 writes: super::common::memory_galaxy_writes(),
928 reads: super::common::memory_galaxy_reads(),
929 ..Default::default()
930 },
931 }
932 }
933}
934
935#[async_trait]
936impl Tool for MemoryTagTool {
937 fn name(&self) -> &str {
938 "memory.tag"
939 }
940 fn gana(&self) -> Gana {
941 Gana::Net
942 }
943 fn effects(&self) -> &EffectRow {
944 &self.effects
945 }
946 fn description(&self) -> &str {
947 "Add or remove tags from a memory"
948 }
949 fn input_schema(&self) -> Value {
950 super::common::schema(
951 &json!({
952 "id": super::common::str_prop("Memory UUID to tag"),
953 "tags": super::common::str_array_prop("Tags to apply"),
954 "action": super::common::str_prop("Action: add (default) | remove"),
955 "galaxy": super::common::str_prop("Galaxy (default: codex)"),
956 }),
957 &["id", "tags"],
958 )
959 }
960 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
961 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
962 let id_str = args
963 .get("id")
964 .and_then(|v| v.as_str())
965 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
966 let id = uuid::Uuid::parse_str(id_str)
967 .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
968 let mut mem = self
969 .store
970 .get(galaxy, id)?
971 .ok_or_else(|| wm_core::CoreError::NotFound(format!("Memory {id} not found")))?;
972 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
973 let tags = args
974 .get("tags")
975 .and_then(|v| v.as_array())
976 .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'tags' array".into()))?;
977 let tag_list: Vec<String> = tags
978 .iter()
979 .filter_map(|t| t.as_str().map(String::from))
980 .collect();
981 match action {
982 "remove" => {
983 mem.metadata.tags.retain(|t| !tag_list.contains(t));
984 }
985 _ => {
986 for t in &tag_list {
987 if !mem.metadata.tags.contains(t) {
988 mem.metadata.tags.push(t.clone());
989 }
990 }
991 }
992 }
993 self.store.put(galaxy, &mem)?;
994 Ok(json!({
995 "status": "success",
996 "id": mem.metadata.id,
997 "action": action,
998 "tags": mem.metadata.tags,
999 }))
1000 }
1001 fn stats(&self) -> &ToolStats {
1002 &self.stats
1003 }
1004}
1005
1006pub struct MemoryStatsTool {
1008 store: Arc<MemoryStore>,
1009 stats: ToolStats,
1010 effects: EffectRow,
1011}
1012
1013impl MemoryStatsTool {
1014 pub fn new(store: Arc<MemoryStore>) -> Self {
1015 Self {
1016 store,
1017 stats: ToolStats::default(),
1018 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1019 }
1020 }
1021}
1022
1023#[async_trait]
1024impl Tool for MemoryStatsTool {
1025 fn input_schema(&self) -> Value {
1026 schema(
1027 &json!({
1028 "galaxy": super::common::str_prop("Galaxy to summarize (optional; default codex)"),
1029 }),
1030 &[],
1031 )
1032 }
1033 fn name(&self) -> &str {
1034 "memory.stats"
1035 }
1036 fn gana(&self) -> Gana {
1037 Gana::WinnowingBasket
1038 }
1039 fn effects(&self) -> &EffectRow {
1040 &self.effects
1041 }
1042 fn description(&self) -> &str {
1043 "Statistics for a galaxy (count, avg importance, tag frequency)"
1044 }
1045 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1046 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
1047 let memories = self.store.scan(galaxy, 10_000)?;
1048 let total = memories.len();
1049 let avg_importance = if total > 0 {
1050 memories.iter().map(|m| m.metadata.importance).sum::<f32>() / total as f32
1051 } else {
1052 0.0
1053 };
1054 let mut tag_freq: HashMap<String, u32> = HashMap::new();
1055 for mem in &memories {
1056 for tag in &mem.metadata.tags {
1057 *tag_freq.entry(tag.clone()).or_insert(0) += 1;
1058 }
1059 }
1060 let top_tags: Vec<(String, u32)> = tag_freq.into_iter().filter(|(_, c)| *c >= 2).collect();
1061 Ok(json!({
1062 "status": "success",
1063 "galaxy": galaxy_name(galaxy),
1064 "count": total,
1065 "avg_importance": (avg_importance * 100.0).round() / 100.0,
1066 "tag_clusters": top_tags.len(),
1067 "top_tags": top_tags.into_iter().take(10).collect::<Vec<_>>(),
1068 }))
1069 }
1070 fn stats(&self) -> &ToolStats {
1071 &self.stats
1072 }
1073}
1074
1075pub struct MemoryHybridRecallTool {
1078 store: Arc<MemoryStore>,
1079 search: Option<Arc<SearchEngine>>,
1080 recall: Option<Arc<RecallEngine>>,
1081 associations: Option<Arc<AssociationStore>>,
1082 stats: ToolStats,
1083 effects: EffectRow,
1084 route_name: &'static str,
1085}
1086
1087impl MemoryHybridRecallTool {
1088 pub fn new(
1089 store: Arc<MemoryStore>,
1090 search: Option<Arc<SearchEngine>>,
1091 recall: Option<Arc<RecallEngine>>,
1092 ) -> Self {
1093 Self::named("memory.hybrid_recall", store, search, recall)
1094 }
1095
1096 pub fn as_search(
1098 store: Arc<MemoryStore>,
1099 search: Option<Arc<SearchEngine>>,
1100 recall: Option<Arc<RecallEngine>>,
1101 ) -> Self {
1102 Self::named("memory.search", store, search, recall)
1103 }
1104
1105 #[must_use]
1109 pub fn with_associations(mut self, associations: Option<Arc<AssociationStore>>) -> Self {
1110 self.associations = associations;
1111 self
1112 }
1113
1114 fn named(
1115 route_name: &'static str,
1116 store: Arc<MemoryStore>,
1117 search: Option<Arc<SearchEngine>>,
1118 recall: Option<Arc<RecallEngine>>,
1119 ) -> Self {
1120 Self {
1121 store,
1122 search,
1123 recall,
1124 associations: None,
1125 stats: ToolStats::default(),
1126 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1127 route_name,
1128 }
1129 }
1130}
1131
1132pub struct MemoryReembedTool {
1139 recall: Option<Arc<RecallEngine>>,
1140 stats: ToolStats,
1141 effects: EffectRow,
1142}
1143
1144impl MemoryReembedTool {
1145 #[must_use]
1146 pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1147 Self {
1148 recall,
1149 stats: ToolStats::default(),
1150 effects: EffectRow {
1151 writes: {
1152 let mut writes = super::common::memory_galaxy_writes();
1153 writes.push(Resource::Galaxy("embeddings".into()));
1154 writes
1155 },
1156 reads: super::common::memory_galaxy_reads(),
1157 destructive: false,
1158 ..Default::default()
1159 },
1160 }
1161 }
1162}
1163
1164#[async_trait]
1165impl Tool for MemoryReembedTool {
1166 fn name(&self) -> &str {
1167 "memory.reembed"
1168 }
1169 fn gana(&self) -> Gana {
1170 Gana::WinnowingBasket
1171 }
1172 fn effects(&self) -> &EffectRow {
1173 &self.effects
1174 }
1175 fn stats(&self) -> &ToolStats {
1176 &self.stats
1177 }
1178 fn description(&self) -> &str {
1179 "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."
1180 }
1181 fn input_schema(&self) -> Value {
1182 schema(
1183 &json!({
1184 "galaxy": str_prop("Only this galaxy (optional; default: all memory galaxies)"),
1185 "limit": int_prop("Maximum vectors to embed this pass (default 200; 0 = no cap)"),
1186 "dry_run": bool_prop("Plan only, no writes (default true)"),
1187 }),
1188 &[],
1189 )
1190 }
1191 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1192 let galaxy = match args.get("galaxy").and_then(|v| v.as_str()) {
1193 Some(name) => Some(parse_galaxy(name)?),
1194 None => None,
1195 };
1196 let limit = args
1197 .get("limit")
1198 .and_then(serde_json::Value::as_u64)
1199 .map_or(200usize, |v| v as usize);
1200 let dry_run = args
1201 .get("dry_run")
1202 .and_then(serde_json::Value::as_bool)
1203 .unwrap_or(true);
1204
1205 let Some(recall) = self.recall.as_ref() else {
1206 return Ok(json!({
1207 "status": "error",
1208 "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",
1209 }));
1210 };
1211 let report = recall.backfill_embeddings(galaxy, limit, dry_run)?;
1212 let mut out = serde_json::to_value(&report).unwrap_or_else(|_| json!({}));
1213 if let Some(obj) = out.as_object_mut() {
1214 obj.insert("status".into(), json!("success"));
1215 obj.insert(
1216 "hint".into(),
1217 json!(if dry_run {
1218 "dry-run only — call again with dry_run: false to persist vectors"
1219 } else {
1220 "vectors persisted; the shared vector index is updated in this process, and restarted processes rehydrate it on first hybrid search"
1221 }),
1222 );
1223 }
1224 Ok(out)
1225 }
1226}
1227
1228fn recall_visible(galaxy: Galaxy, galaxy_explicit: bool) -> bool {
1235 galaxy_explicit || galaxy != Galaxy::Telemetry
1236}
1237
1238fn empty_result_hint(store: &MemoryStore, galaxy: Galaxy) -> String {
1243 let mut populated: Vec<String> = Vec::new();
1244 let mut requested_total = 0usize;
1245 for g in Galaxy::memory_galaxies() {
1246 if g == galaxy {
1247 requested_total = store.count(g).unwrap_or(0);
1248 continue;
1249 }
1250 let n = store.count(g).unwrap_or(0);
1251 if n > 0 {
1252 populated.push(format!("{} ({})", g.db_name(), n));
1253 }
1254 }
1255 let location = if requested_total == 0 {
1256 format!("galaxy '{}' contains no memories", galaxy_name(galaxy))
1257 } else {
1258 format!(
1259 "no matches for this query in '{}' ({} memories)",
1260 galaxy_name(galaxy),
1261 requested_total
1262 )
1263 };
1264 if populated.is_empty() {
1265 format!("{location}; the store is empty")
1266 } else {
1267 format!(
1268 "{}; other galaxies with content: {}. Pass an explicit \"galaxy\" to search there.",
1269 location,
1270 populated.join(", ")
1271 )
1272 }
1273}
1274
1275fn empty_result_hint_all(store: &MemoryStore) -> String {
1278 let mut populated: Vec<String> = Vec::new();
1279 let mut total = 0usize;
1280 for g in Galaxy::memory_galaxies() {
1281 let n = store.count(g).unwrap_or(0);
1282 total += n;
1283 if n > 0 {
1284 populated.push(format!("{} ({})", g.db_name(), n));
1285 }
1286 }
1287 if populated.is_empty() {
1288 "no matches for this query; the store is empty".to_string()
1289 } else {
1290 format!(
1291 "no matches for this query across all memory galaxies ({} total); populated: {}",
1292 total,
1293 populated.join(", ")
1294 )
1295 }
1296}
1297
1298#[async_trait]
1299impl Tool for MemoryHybridRecallTool {
1300 fn name(&self) -> &str {
1301 self.route_name
1302 }
1303 fn gana(&self) -> Gana {
1304 Gana::WinnowingBasket
1305 }
1306 fn effects(&self) -> &EffectRow {
1307 &self.effects
1308 }
1309 fn description(&self) -> &str {
1310 "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|bm25|episodic|fts|importance|cold|none) — bm25 means the fusion ranked with the vector weight configured to zero (the query embed is skipped when the vector half cannot contribute). memory.hybrid_recall is a compatibility alias."
1311 }
1312 fn input_schema(&self) -> Value {
1313 schema(
1314 &json!({
1315 "query": str_prop("Full-text query"),
1316 "galaxy": str_prop("Galaxy filter (optional; default: search all memory galaxies, results labeled; \"all\" is accepted as an alias for the unfiltered default)"),
1317 "limit": super::common::positive_int_prop("Maximum results (default 10; must be >= 1)"),
1318 "min_importance": num_prop("Minimum memory importance (0-1)"),
1319 "min_score": num_prop("Absolute BM25 score floor"),
1320 "min_score_ratio": num_prop("Relative floor: reject hits below this fraction of the top score"),
1321 "min_trust": num_prop("Minimum source_trust (0-1): drop results below this trust floor"),
1322 "include_cold": bool_prop("Opt-in unranked cold recovery (no thaw). Trust/importance floors apply; BM25 floors do not apply to unscored recovery. Search content is scrubbed navigation capped at 8192 characters; read by id/galaxy for the exact original."),
1323 "cold_scan_limit": int_prop("Maximum cold records to scan when include_cold is set (default 2048)"),
1324 }),
1325 &["query"],
1326 )
1327 }
1328 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1329 let galaxy_arg = galaxy_search_arg(args.get("galaxy").and_then(|v| v.as_str()));
1330 let galaxy_explicit = galaxy_arg.is_some();
1331 let galaxy = parse_galaxy_or(galaxy_arg, Galaxy::Codex)?;
1332 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
1333 let limit = super::common::positive_usize_arg(&args, "limit", 10)
1336 .map_err(wm_core::CoreError::InvalidArgs)?;
1337 let include_cold = args
1338 .get("include_cold")
1339 .and_then(serde_json::Value::as_bool)
1340 .unwrap_or(false);
1341 let cold_scan_limit = args
1342 .get("cold_scan_limit")
1343 .and_then(serde_json::Value::as_u64)
1344 .map_or(2048, |v| v.clamp(1, 100_000) as usize);
1345 let min_importance = super::common::bounded_f64_arg(&args, "min_importance", 0.0, Some(1.0))
1346 .map_err(wm_core::CoreError::InvalidArgs)?
1347 .unwrap_or(0.0) as f32;
1348 let min_score = super::common::bounded_f64_arg(&args, "min_score", 0.0, None)
1352 .map_err(wm_core::CoreError::InvalidArgs)?
1353 .map(|v| v as f32)
1354 .filter(|v| *v > 0.0);
1355 let min_score_ratio =
1360 super::common::bounded_f64_arg(&args, "min_score_ratio", 0.0, Some(1.0))
1361 .map_err(wm_core::CoreError::InvalidArgs)?
1362 .map_or(Some(0.05), |v| Some(v as f32));
1363 let mut results = Vec::new();
1364
1365 let trust_weight = std::env::var("WM_TRUST_WEIGHT")
1370 .ok()
1371 .and_then(|v| v.parse::<f32>().ok())
1372 .unwrap_or(0.0)
1373 .clamp(0.0, 1.0);
1374 let mut result_extra: Option<serde_json::Value> = None;
1376 let mut trust_disclosure: Option<serde_json::Value> = None;
1377
1378 let mut recall_mode = "none";
1382 let mut hybrid_available = self
1387 .recall
1388 .as_ref()
1389 .is_some_and(|recall| recall.embedder_is_real());
1390 let fused_mode = fused_mode_label(
1395 self.recall
1396 .as_ref()
1397 .map_or(1.0, |recall| recall.config().vector_weight),
1398 );
1399
1400 if hybrid_available {
1407 let recall = self.recall.as_ref().expect("hybrid_available checked");
1408 if !query.is_empty() {
1409 let (hybrid_results, conformal) = recall.hybrid_search_with_disclosure(
1410 query,
1411 limit * 2,
1412 galaxy_explicit.then_some(galaxy),
1413 );
1414 for hr in hybrid_results {
1415 if !recall_visible(hr.galaxy, galaxy_explicit) {
1416 continue;
1417 }
1418 if let Ok(Some(mem)) = self.store.get(hr.galaxy, hr.memory_id) {
1419 if mem.metadata.importance >= min_importance
1420 && crate::expansion::common::mcp_visible(&mem)
1421 && crate::expansion::common::validity_visible(&mem)
1422 {
1423 let navigation = wm_memory::scrub_text(&mem.content);
1424 results.push(with_navigation_disclosure(
1425 json!({
1426 "id": mem.metadata.id,
1427 "galaxy": mem.metadata.galaxy.db_name(),
1428 "content": navigation,
1429 "importance": mem.metadata.importance,
1430 "score": hr.score,
1431 "raw_score": hr.raw_bm25_score,
1432 "trust_factor": hr.trust_factor,
1433 "corroboration": hr.corroboration,
1434 "in_conformal_set": hr.in_conformal_set,
1435 "bm25_score": hr.bm25_score,
1436 "vector_score": hr.vector_score,
1437 "trust": mem.metadata.source_trust,
1438 "source": fused_mode,
1439 }),
1440 &mem.content,
1441 ));
1442 }
1443 }
1444 }
1445 if let Some(info) = conformal {
1449 result_extra = serde_json::to_value(&info).ok();
1450 }
1451 if trust_weight > 0.0 {
1452 trust_disclosure = Some(json!({
1453 "wm_trust_weight": trust_weight,
1454 "applied_in": "fuse_results",
1455 }));
1456 }
1457 if !results.is_empty() {
1458 recall_mode = fused_mode;
1459 }
1460 }
1461 }
1462
1463 let mut hybrid_degraded: Option<String> = None;
1469 if hybrid_available && results.is_empty() && !query.is_empty() {
1470 if let Some(recall) = self.recall.as_ref() {
1471 if let Err(error) = recall.embedder_probe() {
1472 hybrid_degraded = Some(error.to_string());
1473 hybrid_available = false;
1474 }
1475 }
1476 }
1477
1478 if results.is_empty() && !query.is_empty() && !hybrid_available {
1490 const EPISODIC_RECALL_POOL: usize = 100;
1491 let pool = limit.max(EPISODIC_RECALL_POOL);
1492 let episodic_hits = match self
1495 .store
1496 .episodic()
1497 .search_with_limits(query, pool, pool, false)
1498 {
1499 Ok(hits) => hits,
1500 Err(error) => {
1501 tracing::warn!("episodic default-route search failed: {error}");
1502 Vec::new()
1503 }
1504 };
1505 for er in episodic_hits {
1506 let Some((hit_galaxy, mem)) =
1509 resolve_memory_across_galaxies(&self.store, er.record.id)
1510 else {
1511 continue;
1512 };
1513 if galaxy_explicit && hit_galaxy != galaxy {
1514 continue;
1515 }
1516 if !recall_visible(hit_galaxy, galaxy_explicit) {
1517 continue;
1518 }
1519 if mem.metadata.importance < min_importance
1520 || !crate::expansion::common::mcp_visible(&mem)
1521 || !crate::expansion::common::validity_visible(&mem)
1522 {
1523 continue;
1524 }
1525 let navigation = wm_memory::scrub_text(&mem.content);
1526 results.push(with_navigation_disclosure(
1527 json!({
1528 "id": mem.metadata.id,
1529 "galaxy": hit_galaxy.db_name(),
1530 "content": navigation,
1531 "importance": mem.metadata.importance,
1532 "score": er.score,
1533 "matched_terms": er.matched_terms,
1534 "trust": mem.metadata.source_trust,
1535 "source": "episodic",
1536 }),
1537 &mem.content,
1538 ));
1539 }
1540 if !results.is_empty() {
1541 recall_mode = "episodic";
1542 }
1543 }
1544
1545 if results.is_empty() {
1548 if let Some(ref search) = self.search {
1549 if !query.is_empty() {
1550 let opts = wm_memory::SearchOptions {
1551 limit: limit * 2,
1552 min_score,
1553 relative_floor: min_score_ratio,
1554 galaxy: galaxy_explicit.then_some(galaxy),
1562 ..wm_memory::SearchOptions::default()
1563 };
1564 let hits = search.search_opt(query, &opts)?;
1565 for hit in hits {
1566 if let Ok(id) = uuid::Uuid::parse_str(&hit.memory_id) {
1567 let hit_galaxy = if galaxy_explicit {
1571 Some(galaxy)
1572 } else {
1573 wm_core::Galaxy::all()
1574 .into_iter()
1575 .find(|g| g.db_name() == hit.galaxy)
1576 };
1577 let Some(hit_galaxy) = hit_galaxy else {
1578 continue;
1579 };
1580 if !recall_visible(hit_galaxy, galaxy_explicit) {
1581 continue;
1582 }
1583 if let Ok(Some(mem)) = self.store.get(hit_galaxy, id) {
1584 if mem.metadata.importance >= min_importance
1585 && crate::expansion::common::mcp_visible(&mem)
1586 && crate::expansion::common::validity_visible(&mem)
1587 {
1588 let navigation = wm_memory::scrub_text(&mem.content);
1589 results.push(with_navigation_disclosure(
1590 json!({
1591 "id": mem.metadata.id,
1592 "galaxy": hit_galaxy.db_name(),
1593 "content": navigation,
1594 "importance": mem.metadata.importance,
1595 "score": wm_memory::trust_weighted_score(
1596 hit.score,
1597 mem.metadata.source_trust,
1598 trust_weight,
1599 ),
1600 "raw_score": hit.score,
1601 "normalized_score": hit.normalized_score,
1602 "trust": mem.metadata.source_trust,
1603 "source": "fts",
1604 }),
1605 &mem.content,
1606 ));
1607 if recall_mode == "none" {
1608 recall_mode = "fts";
1609 }
1610 }
1611 }
1612 }
1613 }
1614 }
1615 }
1616 }
1617 if results.is_empty() && query.is_empty() {
1621 let mut memories = self.store.scan(galaxy, 100)?;
1622 memories.sort_by(|a, b| {
1623 b.metadata
1624 .importance
1625 .partial_cmp(&a.metadata.importance)
1626 .unwrap_or(std::cmp::Ordering::Equal)
1627 });
1628 for mem in memories
1629 .iter()
1630 .filter(|m| {
1631 m.metadata.importance >= min_importance
1632 && crate::expansion::common::mcp_visible(m)
1633 && crate::expansion::common::validity_visible(m)
1634 })
1635 .take(limit)
1636 {
1637 results.push(json!({
1638 "id": mem.metadata.id,
1639 "content": &mem.content,
1640 "importance": mem.metadata.importance,
1641 "trust": mem.metadata.source_trust,
1642 "score": mem.metadata.importance,
1643 "source": "importance",
1644 "content_representation": "verbatim",
1645 "content_truncated": false,
1646 "content_scrubbed": false,
1647 "exact_read_available": true,
1648 }));
1649 if recall_mode == "none" {
1650 recall_mode = "importance";
1651 }
1652 }
1653 }
1654 if !results.is_empty() {
1659 if let Some(assoc_store) = &self.associations {
1660 let mut anchors: Vec<(uuid::Uuid, f32)> = results
1661 .iter()
1662 .filter_map(|r| {
1663 let id = r.get("id")?.as_str()?;
1664 uuid::Uuid::parse_str(id).ok().map(|u| {
1665 (
1666 u,
1667 r.get("score")
1668 .and_then(serde_json::Value::as_f64)
1669 .unwrap_or(0.0) as f32,
1670 )
1671 })
1672 })
1673 .collect();
1674 anchors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1675 anchors.dedup_by(|a, b| a.0 == b.0);
1676 anchors.truncate(5);
1677
1678 let mut expansions: Vec<(uuid::Uuid, f32, f32, String, uuid::Uuid)> = Vec::new();
1679 for (seed_id, seed_score) in &anchors {
1680 let mut links = Vec::new();
1681 if let Ok(outgoing) = assoc_store.find_from(self.store.env(), *seed_id) {
1682 links.extend(outgoing);
1683 }
1684 if let Ok(incoming) = assoc_store.find_to(self.store.env(), *seed_id) {
1685 links.extend(incoming);
1686 }
1687 for assoc in links {
1688 if assoc.weight < 0.05 {
1689 continue;
1690 }
1691 let neighbor = if assoc.target == *seed_id {
1692 assoc.source
1693 } else {
1694 assoc.target
1695 };
1696 let score = seed_score * assoc.weight * 0.5;
1697 if score <= 0.0 {
1698 continue;
1699 }
1700 expansions.push((
1701 neighbor,
1702 score,
1703 assoc.weight,
1704 assoc.link_type.as_str().to_string(),
1705 *seed_id,
1706 ));
1707 }
1708 }
1709 expansions.sort_by(|a, b| {
1710 b.1.partial_cmp(&a.1)
1711 .unwrap_or(std::cmp::Ordering::Equal)
1712 .then_with(|| a.0.cmp(&b.0))
1713 });
1714 expansions.dedup_by(|a, b| a.0 == b.0);
1715 expansions.truncate(5);
1716
1717 let direct_ids: Vec<String> = results
1718 .iter()
1719 .filter_map(|r| {
1720 r.get("id")
1721 .and_then(serde_json::Value::as_str)
1722 .map(String::from)
1723 })
1724 .collect();
1725 for (neighbor_id, score, weight, link_type, seed_id) in expansions {
1726 if direct_ids.iter().any(|id| id == &neighbor_id.to_string()) {
1727 continue;
1728 }
1729 let Some((_, mem)) = resolve_memory_across_galaxies(&self.store, neighbor_id)
1730 else {
1731 continue;
1732 };
1733 if !recall_visible(mem.metadata.galaxy, galaxy_explicit) {
1734 continue;
1735 }
1736 if mem.metadata.importance < min_importance
1737 || !crate::expansion::common::mcp_visible(&mem)
1738 || !crate::expansion::common::validity_visible(&mem)
1739 {
1740 continue;
1741 }
1742 let navigation = wm_memory::scrub_text(&mem.content);
1743 results.push(with_navigation_disclosure(
1744 json!({
1745 "id": mem.metadata.id,
1746 "content": navigation,
1747 "importance": mem.metadata.importance,
1748 "trust": mem.metadata.source_trust,
1749 "score": score,
1750 "weight": weight,
1751 "link_type": link_type,
1752 "via": seed_id.to_string(),
1753 "source": "association",
1754 }),
1755 &mem.content,
1756 ));
1757 }
1758 }
1759 }
1760 if trust_weight > 0.0 {
1763 results.sort_by(|a, b| {
1764 b.get("score")
1765 .and_then(serde_json::Value::as_f64)
1766 .partial_cmp(&a.get("score").and_then(serde_json::Value::as_f64))
1767 .unwrap_or(std::cmp::Ordering::Equal)
1768 });
1769 }
1770 let min_trust = super::common::bounded_f64_arg(&args, "min_trust", 0.0, Some(1.0))
1777 .map_err(wm_core::CoreError::InvalidArgs)?;
1778 let pre_filter = results.len();
1779 if let Some(min) = min_trust {
1780 results.retain(
1781 |r| match r.get("trust").and_then(serde_json::Value::as_f64) {
1782 Some(t) => (t as f32) >= min as f32,
1783 None => false,
1784 },
1785 );
1786 }
1787 let min_trust_filtered = pre_filter - results.len();
1788 results.truncate(limit);
1789 let cold_discovery: Option<serde_json::Value> = if include_cold && !query.is_empty() {
1800 let terms: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
1801 let remaining = limit.saturating_sub(results.len());
1802 if remaining == 0 {
1803 Some(
1804 json!({"enabled":true,"scanned":0,"candidates":0,"matched":0,"appended":0,"integrity_rejected":0,"private_skipped":0,"non_current_skipped":0,"eligibility_skipped":0,"stop_reason":"no_headroom","exhausted":false,"no_thaw":true,"ranked":false,"scan_order":"uuid_key","score_floors":"not_applicable_unscored_recovery"}),
1805 )
1806 } else {
1807 let existing: std::collections::HashSet<String> = results
1808 .iter()
1809 .filter_map(|r| {
1810 r.get("id")
1811 .and_then(serde_json::Value::as_str)
1812 .map(str::to_string)
1813 })
1814 .collect();
1815 let outcome = self.store.find_cold_matching_eligible(
1816 &terms,
1817 if galaxy_explicit { Some(galaxy) } else { None },
1818 remaining,
1819 cold_scan_limit,
1820 |mem| {
1821 recall_visible(mem.metadata.galaxy, galaxy_explicit)
1822 && mem.metadata.importance >= min_importance
1823 && min_trust
1824 .is_none_or(|floor| f64::from(mem.metadata.source_trust) >= floor)
1825 && !existing.contains(&mem.metadata.id.to_string())
1826 },
1827 )?;
1828 let mut appended = 0usize;
1829 for record in &outcome.records {
1830 if appended >= remaining {
1831 break;
1832 }
1833 let id = record.id.to_string();
1834 if existing.contains(&id) {
1835 continue;
1836 }
1837 let mem = record.decompress()?;
1838 let navigation = wm_memory::search::scrub_text(&mem.content);
1839 results.push(with_navigation_disclosure(
1840 json!({
1841 "id": id,
1842 "galaxy": mem.metadata.galaxy.db_name(),
1843 "content": navigation,
1844 "importance": mem.metadata.importance,
1845 "trust": mem.metadata.source_trust,
1846 "score": serde_json::Value::Null,
1847 "source": "cold",
1848 "cold": true,
1849 "integrity": "verified",
1850 "model_visible": !mem.metadata.model_exclude,
1851 "tags": &mem.metadata.tags,
1852 }),
1853 &mem.content,
1854 ));
1855 appended += 1;
1856 }
1857 if appended > 0 && recall_mode == "none" {
1858 recall_mode = "cold";
1859 }
1860 Some(json!({
1861 "enabled": true,
1862 "scanned": outcome.scanned,
1863 "candidates": outcome.candidates,
1864 "matched": outcome.matched,
1865 "appended": appended,
1866 "integrity_rejected": outcome.integrity_rejected,
1867 "private_skipped": outcome.private_skipped,
1868 "non_current_skipped": outcome.non_current_skipped,
1869 "eligibility_skipped": outcome.eligibility_skipped,
1870 "stop_reason": outcome.stop_reason,
1871 "exhausted": outcome.stop_reason == wm_memory::cold_storage::ColdDiscoveryStop::Exhausted,
1872 "ranked": false,
1873 "scan_order": "uuid_key",
1874 "score_floors": "not_applicable_unscored_recovery",
1875 "no_thaw": true,
1876 }))
1877 }
1878 } else {
1879 None
1880 };
1881 let evidence_bundle = build_evidence_bundle(&self.store, &results);
1882 let bytes_injected: usize = results
1887 .iter()
1888 .filter_map(|r| r.get("content").and_then(serde_json::Value::as_str))
1889 .map(str::len)
1890 .sum();
1891 let bytes_available: usize = results
1892 .iter()
1893 .filter_map(|r| r.get("id").and_then(serde_json::Value::as_str))
1894 .filter_map(|id| uuid::Uuid::parse_str(id).ok())
1895 .filter_map(|id| resolve_memory_across_galaxies(&self.store, id))
1896 .map(|(_, mem)| mem.content.len())
1897 .sum();
1898 super::common::append_savings_row(
1899 &self.store,
1900 &json!({
1901 "ts_ms": wm_core::time::now_unix_millis(),
1902 "op": "recall",
1903 "tool": self.route_name,
1904 "recall_mode": recall_mode,
1905 "results": results.len(),
1906 "bytes_available": bytes_available,
1907 "bytes_injected": bytes_injected,
1908 }),
1909 );
1910 let hint = if results.is_empty() && !query.is_empty() {
1911 Some(if galaxy_explicit {
1912 empty_result_hint(&self.store, galaxy)
1913 } else {
1914 empty_result_hint_all(&self.store)
1915 })
1916 } else {
1917 None
1918 };
1919 let mut out = json!({
1920 "status": "success",
1921 "galaxy": if galaxy_explicit {
1922 serde_json::Value::from(galaxy_name(galaxy))
1923 } else {
1924 serde_json::Value::from("all")
1925 },
1926 "count": results.len(),
1927 "recall_mode": recall_mode,
1928 "results": results,
1929 "hint": hint,
1930 });
1931 if let Some(extra) = result_extra {
1934 out["conformal_set"] = extra;
1935 }
1936 if let Some(td) = trust_disclosure {
1937 out["trust_weighting"] = td;
1938 }
1939 if let Some(reason) = hybrid_degraded {
1943 out["hybrid_degraded"] = json!(reason);
1944 }
1945 if let Some(min) = min_trust {
1946 out["min_trust"] = json!(min);
1947 out["min_trust_filtered"] = json!(min_trust_filtered);
1948 }
1949 if let Some(cd) = cold_discovery {
1950 out["cold_discovery"] = cd;
1951 }
1952 out["evidence_bundle"] = evidence_bundle;
1953 if !query.is_empty() && results.is_empty() {
1954 out["abstention"] = json!({
1955 "status": "insufficient_evidence",
1956 "reason": "no_results_above_floors",
1957 "scope": "retrieval",
1958 });
1959 } else if !query.is_empty() {
1960 if let Some(abstention) = weak_evidence_abstention(&results, query) {
1966 out["abstention"] = abstention;
1967 }
1968 }
1969 Ok(out)
1970 }
1971 fn stats(&self) -> &ToolStats {
1972 &self.stats
1973 }
1974}
1975
1976pub struct MemoryRecallFeedbackTool {
1986 recall: Option<Arc<RecallEngine>>,
1987 stats: ToolStats,
1988 effects: EffectRow,
1989}
1990
1991impl MemoryRecallFeedbackTool {
1992 #[must_use]
1993 pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1994 Self {
1995 recall,
1996 stats: ToolStats::default(),
1997 effects: EffectRow {
2002 writes: vec![Resource::Filesystem],
2003 ..Default::default()
2004 },
2005 }
2006 }
2007}
2008
2009#[async_trait]
2010impl Tool for MemoryRecallFeedbackTool {
2011 fn name(&self) -> &str {
2012 "memory.recall_feedback"
2013 }
2014 fn gana(&self) -> Gana {
2015 Gana::WinnowingBasket
2016 }
2017 fn effects(&self) -> &EffectRow {
2018 &self.effects
2019 }
2020 fn description(&self) -> &str {
2021 "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."
2022 }
2023 fn input_schema(&self) -> Value {
2024 schema(
2025 &json!({
2026 "samples": {"type": "array", "items": {"type": "object"}, "description": "Feedback samples: [{score: 0-1 fused score, relevant: bool}]"},
2027 "score": num_prop("Single-sample fused score (0-1)"),
2028 "relevant": {"type": "boolean", "description": "Single-sample relevance label"},
2029 }),
2030 &[],
2031 )
2032 }
2033 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2034 let Some(ref recall) = self.recall else {
2035 return Ok(json!({
2036 "status": "error",
2037 "message": "no recall engine on this server (hybrid search unavailable) — nothing to calibrate",
2038 }));
2039 };
2040 let mut samples: Vec<(f32, bool)> = Vec::new();
2041 if let Some(list) = args.get("samples").and_then(Value::as_array) {
2042 for s in list {
2043 let score = s.get("score").and_then(Value::as_f64).unwrap_or(-1.0);
2044 let relevant = s.get("relevant").and_then(Value::as_bool);
2045 if !(0.0..=1.0).contains(&score) || relevant.is_none() {
2046 return Err(wm_core::CoreError::InvalidArgs(
2047 "each sample needs score in [0,1] and a boolean 'relevant'".into(),
2048 ));
2049 }
2050 samples.push((score as f32, relevant.unwrap_or(false)));
2051 }
2052 } else if let Some(score) = args.get("score").and_then(Value::as_f64) {
2053 let relevant = args
2054 .get("relevant")
2055 .and_then(Value::as_bool)
2056 .ok_or_else(|| {
2057 wm_core::CoreError::InvalidArgs("'relevant' is required with 'score'".into())
2058 })?;
2059 if !(0.0..=1.0).contains(&score) {
2060 return Err(wm_core::CoreError::InvalidArgs(
2061 "'score' must be within [0,1]".into(),
2062 ));
2063 }
2064 samples.push((score as f32, relevant));
2065 } else {
2066 return Err(wm_core::CoreError::InvalidArgs(
2067 "provide 'samples' (array of {score, relevant}) or a single 'score' + 'relevant'"
2068 .into(),
2069 ));
2070 }
2071
2072 let mut recorded = 0usize;
2073 let mut count = 0usize;
2074 for (score, relevant) in samples {
2075 count = recall.record_relevance_feedback(score, relevant)?;
2076 recorded += 1;
2077 }
2078 let status = recall
2081 .conformal_disclosure(&mut Vec::new())?
2082 .map_or_else(|| "off".into(), |info| info.status);
2083 Ok(json!({
2084 "status": "success",
2085 "recorded": recorded,
2086 "calibration_samples": count,
2087 "conformal_status": status,
2088 }))
2089 }
2090 fn stats(&self) -> &ToolStats {
2091 &self.stats
2092 }
2093}
2094
2095pub struct MemoryEpisodicSearchTool {
2099 store: Arc<MemoryStore>,
2100 stats: ToolStats,
2101 effects: EffectRow,
2102}
2103
2104impl MemoryEpisodicSearchTool {
2105 pub fn new(store: Arc<MemoryStore>) -> Self {
2106 Self {
2107 store,
2108 stats: ToolStats::default(),
2109 effects: EffectRow::read_only(vec![Resource::Galaxy("episodic_records".into())]),
2110 }
2111 }
2112}
2113
2114#[async_trait]
2115impl Tool for MemoryEpisodicSearchTool {
2116 fn name(&self) -> &str {
2117 "memory.episodic_search"
2118 }
2119 fn gana(&self) -> Gana {
2120 Gana::WinnowingBasket
2121 }
2122 fn effects(&self) -> &EffectRow {
2123 &self.effects
2124 }
2125 fn description(&self) -> &str {
2126 "[V6 Experimental] Search explicit episodic records with provenance and lifecycle filtering"
2127 }
2128 fn input_schema(&self) -> Value {
2129 schema(
2130 &json!({
2131 "query": str_prop("Full-text query"),
2132 "limit": int_prop("Maximum results (default 10)"),
2133 "candidate_limit": int_prop("Maximum candidates to score (default 2x limit). This is the deterministic retrieval width — keep it wide for tail recall; bound rerank embedding cost with rerank_pool instead (measured on the 50q set: wide retrieval + small pool beat narrowed retrieval on R@1 and R@10)"),
2134 "include_historical": {
2135 "type": "boolean",
2136 "description": "Include superseded, revoked, and archived records",
2137 },
2138 "rerank": {
2139 "type": "boolean",
2140 "description": "Enable vector reranking (requires embedder, default false)",
2141 },
2142 "rerank_alpha": {
2143 "type": "number",
2144 "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 when the candidate set is not narrowed). Blending alphas can drop correct items out of top-K — prefer >=2.0 when recall@limit matters",
2145 },
2146 "rerank_pool": int_prop("Embedding/reorder width for rerank (default 0 = auto: min(50, max(limit, candidate_limit)), capped at 50). Lower it on CPU embedders to cut latency without narrowing candidate_limit (the deterministic retrieval width)"),
2147 "min_score": {
2148 "type": "number",
2149 "description": "Minimum score threshold; results below this are dropped (abstention). Default 0.0 (no threshold)",
2150 },
2151 "min_coverage": {
2152 "type": "number",
2153 "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)",
2154 },
2155 }),
2156 &["query"],
2157 )
2158 }
2159 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2160 let query = args.get("query").and_then(Value::as_str).unwrap_or("");
2161 let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
2162 let candidate_limit =
2163 args.get("candidate_limit")
2164 .and_then(Value::as_u64)
2165 .unwrap_or_else(|| limit.saturating_mul(2) as u64) as usize;
2166 let include_historical = args
2167 .get("include_historical")
2168 .and_then(Value::as_bool)
2169 .unwrap_or(false);
2170 let rerank = args.get("rerank").and_then(Value::as_bool).unwrap_or(false);
2171 let rerank_alpha = args
2172 .get("rerank_alpha")
2173 .and_then(Value::as_f64)
2174 .unwrap_or(0.7) as f32;
2175 let rerank_pool = args.get("rerank_pool").and_then(Value::as_u64).unwrap_or(0) as usize;
2176 let min_score = args
2177 .get("min_score")
2178 .and_then(Value::as_f64)
2179 .map(|v| v as f32);
2180 let min_coverage = args
2181 .get("min_coverage")
2182 .and_then(Value::as_f64)
2183 .map(|v| v as f32);
2184 let query_term_count: usize = {
2188 const STOPWORDS: &[&str] = &[
2189 "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
2190 "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
2191 "must", "can", "shall", "to", "of", "in", "on", "at", "by", "for", "with", "about",
2192 "as", "into", "like", "through", "after", "over", "between", "out", "against",
2193 "during", "without", "before", "under", "around", "among", "i", "me", "my", "we",
2194 "us", "our", "you", "your", "he", "him", "his", "she", "her", "it", "its", "they",
2195 "them", "their", "what", "whats", "who", "when", "where", "why", "how", "and",
2196 "or", "but", "not", "no", "nor", "so", "yet", "both", "either", "neither", "this",
2197 "that", "these", "those", "there", "here", "now", "then", "than",
2198 ];
2199 query
2200 .split(|c: char| !c.is_alphanumeric())
2201 .filter(|t| t.len() > 1)
2202 .map(str::to_ascii_lowercase)
2203 .filter(|t| !STOPWORDS.contains(&t.as_str()))
2204 .collect::<std::collections::HashSet<_>>()
2205 .len()
2206 };
2207 let raw_results = if rerank {
2208 self.store.episodic().search_with_rerank(
2209 query,
2210 limit,
2211 candidate_limit,
2212 include_historical,
2213 rerank_alpha,
2214 rerank_pool,
2215 )?
2216 } else {
2217 self.store.episodic().search_with_limits(
2218 query,
2219 limit,
2220 candidate_limit,
2221 include_historical,
2222 )?
2223 };
2224 let is_count_query = query.to_ascii_lowercase().contains("how many");
2232 let abstain = min_coverage.is_some()
2233 && !is_count_query
2234 && query_term_count >= 3
2235 && !raw_results.iter().any(|hit| hit.matched_terms >= 2);
2236 let visible: Vec<_> = raw_results
2237 .into_iter()
2238 .filter(|hit| !hit.record.is_private && !hit.record.model_exclude)
2239 .filter(|hit| min_score.is_none_or(|ms| hit.score >= ms))
2240 .filter(|_| !abstain)
2241 .take(limit)
2242 .collect();
2243 let conflicts = detect_conflicts(&visible);
2247 let results = visible
2248 .into_iter()
2249 .map(|hit| {
2250 json!({
2251 "id": hit.record.id,
2252 "content": wm_memory::scrub_text(&hit.record.content),
2253 "score": hit.score,
2254 "matched_terms": hit.matched_terms,
2255 "session_id": hit.record.session_id,
2256 "sequence": hit.record.sequence,
2257 "created_at": hit.record.created_at,
2258 "validity": hit.record.validity,
2259 "provenance": hit.record.provenance,
2260 "source": "episodic",
2261 })
2262 })
2263 .collect::<Vec<_>>();
2264 Ok(json!({
2265 "status": "success",
2266 "count": results.len(),
2267 "current_resolution": wm_memory::episodic::is_current_query(query),
2271 "conflicts": conflicts,
2274 "results": results,
2275 }))
2276 }
2277 fn stats(&self) -> &ToolStats {
2278 &self.stats
2279 }
2280}
2281
2282pub struct MemoryAggregateTool {
2295 search: Option<Arc<SearchEngine>>,
2296 store: Arc<MemoryStore>,
2297 stats: ToolStats,
2298 effects: EffectRow,
2299}
2300
2301impl MemoryAggregateTool {
2302 pub fn new(search: Option<Arc<SearchEngine>>, store: Arc<MemoryStore>) -> Self {
2303 Self {
2304 search,
2305 store,
2306 stats: ToolStats::default(),
2307 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2308 }
2309 }
2310}
2311
2312fn session_ordinal(tags: &[String]) -> Option<u64> {
2314 tags.iter().find_map(|tag| {
2315 let rest = tag.strip_prefix("session_")?;
2316 rest.parse::<u64>().ok()
2317 })
2318}
2319
2320fn contains_term(content: &str, term: &str) -> bool {
2323 let lowered = content.to_ascii_lowercase();
2324 let variants = [term.to_string(), strip_suffix(term)];
2325 for variant in &variants {
2326 if variant.len() < 2 {
2327 continue;
2328 }
2329 let mut start = 0;
2330 while let Some(pos) = lowered[start..].find(variant.as_str()) {
2331 let before_ok = pos == 0
2332 || !lowered[start + pos - 1..start + pos]
2333 .chars()
2334 .next()
2335 .is_some_and(char::is_alphanumeric);
2336 let end = start + pos + variant.len();
2337 let after_ok = end >= lowered.len()
2338 || !lowered[end..]
2339 .chars()
2340 .next()
2341 .is_some_and(char::is_alphanumeric);
2342 if before_ok && after_ok {
2343 return true;
2344 }
2345 start += pos + variant.len();
2346 }
2347 }
2348 false
2349}
2350
2351fn strip_suffix(term: &str) -> String {
2354 for suffix in ["ing", "ed", "es", "s"] {
2355 if let Some(stem) = term.strip_suffix(suffix) {
2356 if stem.len() >= 2 {
2357 return stem.to_string();
2358 }
2359 }
2360 }
2361 term.to_string()
2362}
2363
2364#[async_trait]
2365impl Tool for MemoryAggregateTool {
2366 fn name(&self) -> &str {
2367 "memory.aggregate"
2368 }
2369 fn gana(&self) -> Gana {
2370 Gana::WinnowingBasket
2371 }
2372 fn effects(&self) -> &EffectRow {
2373 &self.effects
2374 }
2375 fn description(&self) -> &str {
2376 "Aggregate over memories matching a query: count, distinct session count, or session span (cross-session synthesis)"
2377 }
2378 fn input_schema(&self) -> Value {
2379 schema(
2380 &json!({
2381 "query": str_prop("Full-text query selecting the memories to aggregate over"),
2382 "metric": str_prop("Aggregate metric: count | session_count | session_span"),
2383 "limit": super::common::positive_int_prop("Maximum candidates considered (default 50; must be >= 1)"),
2384 }),
2385 &["query", "metric"],
2386 )
2387 }
2388 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2389 let query = args
2390 .get("query")
2391 .and_then(Value::as_str)
2392 .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
2393 let metric = args
2394 .get("metric")
2395 .and_then(Value::as_str)
2396 .ok_or_else(|| wm_core::CoreError::InvalidArgs("metric (string) required".into()))?;
2397 let limit = super::common::positive_usize_arg(&args, "limit", 50)
2398 .map_err(wm_core::CoreError::InvalidArgs)?;
2399 let Some(search) = self.search.as_ref() else {
2400 return Err(wm_core::CoreError::Memory(
2401 "search engine unavailable for aggregation".into(),
2402 ));
2403 };
2404
2405 let results = search.search(query, limit)?;
2406 let mut memories = Vec::new();
2408 for r in &results {
2409 let Some(galaxy) = wm_core::Galaxy::from_db_name(&r.galaxy) else {
2410 continue;
2411 };
2412 let Ok(id) = uuid::Uuid::parse_str(&r.memory_id) else {
2413 continue;
2414 };
2415 let Ok(Some(mem)) = self.store.get(galaxy, id) else {
2416 continue;
2417 };
2418 if super::common::mcp_visible(&mem) && super::common::validity_visible(&mem) {
2419 memories.push((r.score, mem));
2420 }
2421 }
2422
2423 let evidence: Vec<Value> = memories
2424 .iter()
2425 .map(|(score, mem)| {
2426 json!({
2427 "memory_id": mem.metadata.id.to_string(),
2428 "score": score,
2429 "content": wm_memory::scrub_text(&mem.content),
2430 "tags": mem.metadata.tags,
2431 })
2432 })
2433 .collect();
2434
2435 let session_tagged: Vec<_> = memories
2444 .iter()
2445 .filter(|(_, mem)| session_ordinal(&mem.metadata.tags).is_some())
2446 .collect();
2447 let (anchored, anchor): (Vec<_>, &str) = if metric == "count" {
2448 (Vec::new(), "none")
2449 } else if session_tagged.len() < 2 {
2450 (session_tagged.clone(), "session_tagged_fallback")
2451 } else {
2452 let terms: Vec<String> = wm_memory::strip_stopwords(query)
2453 .split(|c: char| !c.is_alphanumeric())
2454 .filter(|t| t.len() > 1)
2455 .map(str::to_ascii_lowercase)
2456 .collect();
2457 let mut best: Option<(String, usize)> = None;
2458 for term in &terms {
2459 let count = session_tagged
2460 .iter()
2461 .filter(|(_, mem)| contains_term(&mem.content, term))
2462 .count();
2463 if count == 0 {
2464 continue;
2465 }
2466 let better = best
2467 .as_ref()
2468 .is_none_or(|(_, best_count)| count < *best_count);
2469 if better {
2470 best = Some((term.clone(), count));
2471 }
2472 }
2473 match best {
2474 Some((term, _)) => (
2475 session_tagged
2476 .iter()
2477 .filter(|(_, mem)| contains_term(&mem.content, &term))
2478 .copied()
2479 .collect(),
2480 "rarest_term",
2481 ),
2482 None => (session_tagged.clone(), "session_tagged_fallback"),
2483 }
2484 };
2485
2486 let aggregate = match metric {
2487 "count" => json!({
2488 "metric": "count",
2489 "value": memories.len(),
2490 "content": format!("{} memories", memories.len()),
2491 }),
2492 "session_count" => {
2493 let sessions: std::collections::HashSet<u64> = anchored
2494 .iter()
2495 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2496 .collect();
2497 json!({
2498 "metric": "session_count",
2499 "value": sessions.len(),
2500 "content": format!("{} distinct sessions", sessions.len()),
2501 })
2502 }
2503 "session_span" => {
2504 let ordinals: Vec<u64> = anchored
2505 .iter()
2506 .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
2507 .collect();
2508 if ordinals.is_empty() {
2509 json!({
2510 "metric": "session_span",
2511 "value": null,
2512 "content": "no session-tagged evidence found",
2513 })
2514 } else {
2515 let span = ordinals.iter().max().unwrap() - ordinals.iter().min().unwrap();
2516 json!({
2517 "metric": "session_span",
2518 "value": span,
2519 "unit": "sessions",
2520 "content": format!("{span} sessions"),
2521 })
2522 }
2523 }
2524 other => {
2525 return Err(wm_core::CoreError::InvalidArgs(format!(
2526 "unknown metric '{other}' (count | session_count | session_span)"
2527 )));
2528 }
2529 };
2530
2531 Ok(json!({
2532 "status": "success",
2533 "query": query,
2534 "total": memories.len(),
2535 "session_tagged": session_tagged.len(),
2536 "anchor": anchor,
2537 "limit_hit": results.len() >= limit,
2538 "aggregate": aggregate,
2539 "results": evidence,
2540 }))
2541 }
2542 fn stats(&self) -> &ToolStats {
2543 &self.stats
2544 }
2545}
2546
2547pub struct MemorySortTool {
2549 store: Arc<MemoryStore>,
2550 stats: ToolStats,
2551 effects: EffectRow,
2552}
2553
2554impl MemorySortTool {
2555 pub fn new(store: Arc<MemoryStore>) -> Self {
2556 Self {
2557 store,
2558 stats: ToolStats::default(),
2559 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2560 }
2561 }
2562}
2563
2564#[async_trait]
2565impl Tool for MemorySortTool {
2566 fn input_schema(&self) -> Value {
2567 schema(
2568 &json!({
2569 "galaxy": super::common::str_prop("Galaxy to sort (optional; default codex)"),
2570 "sort_by": super::common::str_prop("Sort key: importance | created_at | accessed_at | access_count"),
2571 "order": super::common::str_prop("Order: asc | desc (default desc)"),
2572 "limit": super::common::int_prop("Maximum entries (default 50)"),
2573 }),
2574 &[],
2575 )
2576 }
2577 fn name(&self) -> &str {
2578 "memory.sort"
2579 }
2580 fn gana(&self) -> Gana {
2581 Gana::WinnowingBasket
2582 }
2583 fn effects(&self) -> &EffectRow {
2584 &self.effects
2585 }
2586 fn description(&self) -> &str {
2587 "Sort memories by importance, recency, or access count"
2588 }
2589 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2590 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2591 let sort_by = args
2592 .get("sort_by")
2593 .and_then(|v| v.as_str())
2594 .unwrap_or("importance");
2595 let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
2596 let limit = args
2597 .get("limit")
2598 .and_then(serde_json::Value::as_u64)
2599 .unwrap_or(50) as usize;
2600
2601 let mut memories = self.store.scan(galaxy, 10_000)?;
2602 memories.retain(|m| {
2605 crate::expansion::common::mcp_visible(m)
2606 && crate::expansion::common::validity_visible(m)
2607 });
2608
2609 match sort_by {
2610 "importance" => memories.sort_by(|a, b| {
2611 b.metadata
2612 .importance
2613 .partial_cmp(&a.metadata.importance)
2614 .unwrap_or(std::cmp::Ordering::Equal)
2615 }),
2616 "recency" => memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.created_at)),
2617 "accessed" => {
2618 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.accessed_at));
2619 }
2620 "access_count" => {
2621 memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.access_count));
2622 }
2623 _ => {
2624 return Err(wm_core::CoreError::InvalidArgs(format!(
2625 "Unknown sort_by: '{sort_by}'. Use importance, recency, accessed, or access_count"
2626 )));
2627 }
2628 }
2629
2630 if order == "asc" {
2631 memories.reverse();
2632 }
2633
2634 let total = memories.len();
2635 memories.truncate(limit);
2636
2637 let results: Vec<Value> = memories
2638 .iter()
2639 .map(|m| {
2640 json!({
2641 "id": m.metadata.id,
2642 "content": &m.content,
2643 "importance": m.metadata.importance,
2644 "created_at": m.metadata.created_at.to_rfc3339(),
2645 "accessed_at": m.metadata.accessed_at.to_rfc3339(),
2646 "access_count": m.metadata.access_count,
2647 "tags": &m.metadata.tags,
2648 })
2649 })
2650 .collect();
2651
2652 Ok(json!({
2653 "status": "success",
2654 "galaxy": galaxy_name(galaxy),
2655 "sort_by": sort_by,
2656 "order": order,
2657 "total": total,
2658 "returned": results.len(),
2659 "memories": results,
2660 }))
2661 }
2662 fn stats(&self) -> &ToolStats {
2663 &self.stats
2664 }
2665}
2666
2667pub struct MemoryFilterTool {
2669 store: Arc<MemoryStore>,
2670 stats: ToolStats,
2671 effects: EffectRow,
2672}
2673
2674impl MemoryFilterTool {
2675 pub fn new(store: Arc<MemoryStore>) -> Self {
2676 Self {
2677 store,
2678 stats: ToolStats::default(),
2679 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2680 }
2681 }
2682}
2683
2684#[async_trait]
2685impl Tool for MemoryFilterTool {
2686 fn name(&self) -> &str {
2687 "memory.filter"
2688 }
2689 fn gana(&self) -> Gana {
2690 Gana::WinnowingBasket
2691 }
2692 fn effects(&self) -> &EffectRow {
2693 &self.effects
2694 }
2695 fn description(&self) -> &str {
2696 "Filter memories by tags, date range, importance thresholds, and a content query substring"
2697 }
2698 fn input_schema(&self) -> Value {
2699 super::common::schema(
2700 &json!({
2701 "galaxy": super::common::str_prop("Galaxy to filter (default codex)"),
2702 "tags": super::common::str_array_prop("Filter: memories with all of these tags"),
2703 "exclude_tags": super::common::str_array_prop("Filter: drop memories carrying any of these tags"),
2704 "min_importance": super::common::num_prop("Filter: minimum importance (0-1)"),
2705 "max_importance": super::common::num_prop("Filter: maximum importance (0-1)"),
2706 "query": super::common::str_prop("Filter: every whitespace-separated term must appear (case-insensitive) in the content or title"),
2707 "created_after": super::common::str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
2708 "created_before": super::common::str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
2709 "limit": super::common::int_prop("Maximum entries (default 50)"),
2710 "offset": super::common::int_prop("Skip this many matching entries before returning (default 0)"),
2711 }),
2712 &[],
2713 )
2714 }
2715 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2716 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2717 let tags: Vec<String> = args
2718 .get("tags")
2719 .and_then(|v| v.as_array())
2720 .map(|arr| {
2721 arr.iter()
2722 .filter_map(|t| t.as_str().map(String::from))
2723 .collect()
2724 })
2725 .unwrap_or_default();
2726 let exclude_tags: Vec<String> = args
2727 .get("exclude_tags")
2728 .and_then(|v| v.as_array())
2729 .map(|arr| {
2730 arr.iter()
2731 .filter_map(|t| t.as_str().map(String::from))
2732 .collect()
2733 })
2734 .unwrap_or_default();
2735 let min_importance = args
2736 .get("min_importance")
2737 .and_then(serde_json::Value::as_f64)
2738 .unwrap_or(0.0) as f32;
2739 let max_importance = args
2740 .get("max_importance")
2741 .and_then(serde_json::Value::as_f64)
2742 .unwrap_or(1.0) as f32;
2743 let limit = args
2744 .get("limit")
2745 .and_then(serde_json::Value::as_u64)
2746 .unwrap_or(50) as usize;
2747 let offset = args
2748 .get("offset")
2749 .and_then(serde_json::Value::as_u64)
2750 .unwrap_or(0) as usize;
2751 let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
2754 match args.get(name).and_then(|v| v.as_str()) {
2755 Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
2756 .map(|t| Some(t.with_timezone(&chrono::Utc)))
2757 .map_err(|_| {
2758 wm_core::CoreError::InvalidArgs(format!(
2759 "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
2760 ))
2761 }),
2762 _ => Ok(None),
2763 }
2764 };
2765 let created_after = parse_bound("created_after")?;
2766 let created_before = parse_bound("created_before")?;
2767 let query_terms: Vec<String> = args
2771 .get("query")
2772 .and_then(|v| v.as_str())
2773 .map(|q| q.split_whitespace().map(str::to_lowercase).collect())
2774 .unwrap_or_default();
2775
2776 let memories = self.store.scan(galaxy, 10_000)?;
2777
2778 let matched: Vec<&wm_memory::Memory> = memories
2779 .iter()
2780 .filter(|m| {
2781 if !crate::expansion::common::mcp_visible(m) {
2783 return false;
2784 }
2785 if !crate::expansion::common::validity_visible(m) {
2787 return false;
2788 }
2789 if m.metadata.importance < min_importance || m.metadata.importance > max_importance
2790 {
2791 return false;
2792 }
2793 if !tags.is_empty() && !tags.iter().all(|t| m.metadata.tags.contains(t)) {
2794 return false;
2795 }
2796 if exclude_tags
2797 .iter()
2798 .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
2799 {
2800 return false;
2801 }
2802 if let Some(after) = created_after {
2803 if m.metadata.created_at < after {
2804 return false;
2805 }
2806 }
2807 if let Some(before) = created_before {
2808 if m.metadata.created_at > before {
2809 return false;
2810 }
2811 }
2812 if !query_terms.is_empty() {
2813 let haystack = match &m.metadata.title {
2814 Some(title) => format!("{}\n{}", m.content, title).to_lowercase(),
2815 None => m.content.to_lowercase(),
2816 };
2817 if !query_terms.iter().all(|t| haystack.contains(t)) {
2818 return false;
2819 }
2820 }
2821 true
2822 })
2823 .collect();
2824 let filtered: Vec<&&wm_memory::Memory> = matched.iter().skip(offset).take(limit).collect();
2826
2827 let total_scanned = memories.len();
2828 let results: Vec<Value> = filtered
2829 .iter()
2830 .map(|m| {
2831 json!({
2832 "id": m.metadata.id,
2833 "content": &m.content,
2834 "importance": m.metadata.importance,
2835 "tags": &m.metadata.tags,
2836 "created_at": m.metadata.created_at.to_rfc3339(),
2837 })
2838 })
2839 .collect();
2840
2841 Ok(json!({
2842 "status": "success",
2843 "galaxy": galaxy_name(galaxy),
2844 "scanned": total_scanned,
2845 "matched": matched.len(),
2846 "offset": offset,
2847 "returned": results.len(),
2848 "filters": {
2849 "tags": tags,
2850 "exclude_tags": exclude_tags,
2851 "min_importance": min_importance,
2852 "max_importance": max_importance,
2853 "query_terms": query_terms,
2854 "created_after": created_after.map(|t| t.to_rfc3339()),
2855 "created_before": created_before.map(|t| t.to_rfc3339()),
2856 },
2857 "memories": results,
2858 }))
2859 }
2860 fn stats(&self) -> &ToolStats {
2861 &self.stats
2862 }
2863}
2864
2865pub struct MemoryDeduplicateTool {
2867 store: Arc<MemoryStore>,
2868 search: Option<Arc<SearchEngine>>,
2869 stats: ToolStats,
2870 effects: EffectRow,
2871}
2872
2873impl MemoryDeduplicateTool {
2874 pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
2875 Self {
2876 store,
2877 search,
2878 stats: ToolStats::default(),
2879 effects: EffectRow {
2880 writes: super::common::memory_galaxy_writes(),
2881 reads: super::common::memory_galaxy_reads(),
2882 destructive: true,
2883 ..Default::default()
2884 },
2885 }
2886 }
2887}
2888
2889#[async_trait]
2890impl Tool for MemoryDeduplicateTool {
2891 fn name(&self) -> &str {
2892 "memory.deduplicate"
2893 }
2894 fn gana(&self) -> Gana {
2895 Gana::WinnowingBasket
2896 }
2897 fn effects(&self) -> &EffectRow {
2898 &self.effects
2899 }
2900 fn description(&self) -> &str {
2901 "Find and merge duplicate memories by content hash or similarity"
2902 }
2903 fn input_schema(&self) -> Value {
2904 super::common::schema(
2905 &json!({
2906 "galaxy": super::common::str_prop("Galaxy to deduplicate"),
2907 "mode": super::common::str_prop("Strategy: hash | similarity (default: hash)"),
2908 "limit": super::common::int_prop("Maximum entries to scan"),
2909 "dry_run": super::common::bool_prop("Preview only (default: true)"),
2910 }),
2911 &["galaxy"],
2912 )
2913 }
2914 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2915 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2916 let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("hash");
2917 let dry_run = args
2918 .get("dry_run")
2919 .and_then(serde_json::Value::as_bool)
2920 .unwrap_or(true);
2921 let limit = args
2922 .get("limit")
2923 .and_then(serde_json::Value::as_u64)
2924 .unwrap_or(10_000) as usize;
2925
2926 let memories = self.store.scan(galaxy, limit)?;
2927
2928 match mode {
2929 "hash" => {
2930 let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
2931 let mut duplicates: Vec<Value> = Vec::new();
2932
2933 for mem in &memories {
2934 let hash = &mem.metadata.content_hash;
2935 if let Some(existing_id) = seen_hashes.get(hash) {
2936 if *existing_id != mem.metadata.id {
2937 duplicates.push(json!({
2938 "id": mem.metadata.id,
2939 "duplicate_of": existing_id,
2940 "content_preview": mem.content.chars().take(100).collect::<String>(),
2941 "importance": mem.metadata.importance,
2942 }));
2943 if !dry_run {
2944 self.store.delete(galaxy, mem.metadata.id)?;
2945 super::common::deindex(
2946 self.search.as_deref(),
2947 &mem.metadata.id.to_string(),
2948 );
2949 }
2950 }
2951 } else {
2952 seen_hashes.insert(hash.clone(), mem.metadata.id);
2953 }
2954 }
2955
2956 let removed = if dry_run { 0 } else { duplicates.len() };
2957
2958 Ok(json!({
2959 "status": "success",
2960 "galaxy": galaxy_name(galaxy),
2961 "mode": mode,
2962 "dry_run": dry_run,
2963 "scanned": memories.len(),
2964 "duplicates_found": duplicates.len(),
2965 "removed": removed,
2966 "duplicates": duplicates,
2967 }))
2968 }
2969 "content" => {
2970 let mut duplicates: Vec<Value> = Vec::new();
2971 let mut removed_count = 0u32;
2972
2973 for i in 0..memories.len() {
2974 for j in (i + 1)..memories.len() {
2975 if memories[i].content == memories[j].content {
2976 duplicates.push(json!({
2977 "id": memories[j].metadata.id,
2978 "duplicate_of": memories[i].metadata.id,
2979 "content_preview": memories[j].content.chars().take(100).collect::<String>(),
2980 }));
2981 if !dry_run {
2982 self.store.delete(galaxy, memories[j].metadata.id)?;
2983 super::common::deindex(
2984 self.search.as_deref(),
2985 &memories[j].metadata.id.to_string(),
2986 );
2987 removed_count += 1;
2988 }
2989 break;
2990 }
2991 }
2992 }
2993
2994 Ok(json!({
2995 "status": "success",
2996 "galaxy": galaxy_name(galaxy),
2997 "mode": mode,
2998 "dry_run": dry_run,
2999 "scanned": memories.len(),
3000 "duplicates_found": duplicates.len(),
3001 "removed": removed_count,
3002 "duplicates": duplicates,
3003 }))
3004 }
3005 _ => Err(wm_core::CoreError::InvalidArgs(format!(
3006 "Unknown mode: '{mode}'. Use 'hash' or 'content'"
3007 ))),
3008 }
3009 }
3010 fn stats(&self) -> &ToolStats {
3011 &self.stats
3012 }
3013}
3014
3015pub struct MemoryExportTool {
3017 store: Arc<MemoryStore>,
3018 stats: ToolStats,
3019 effects: EffectRow,
3020}
3021
3022impl MemoryExportTool {
3023 pub fn new(store: Arc<MemoryStore>) -> Self {
3024 Self {
3025 store,
3026 stats: ToolStats::default(),
3027 effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
3028 }
3029 }
3030}
3031
3032#[async_trait]
3033impl Tool for MemoryExportTool {
3034 fn input_schema(&self) -> Value {
3035 schema(
3036 &json!({
3037 "galaxy": super::common::str_prop("Galaxy to export (optional; default codex)"),
3038 "format": super::common::str_prop("Export format: json | jsonl | markdown"),
3039 "limit": super::common::int_prop("Maximum entries to export"),
3040 }),
3041 &[],
3042 )
3043 }
3044 fn name(&self) -> &str {
3045 "memory.export"
3046 }
3047 fn gana(&self) -> Gana {
3048 Gana::WinnowingBasket
3049 }
3050 fn effects(&self) -> &EffectRow {
3051 &self.effects
3052 }
3053 fn description(&self) -> &str {
3054 "Export memories in JSON, CSV, or Markdown format"
3055 }
3056 async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
3057 let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
3058 let format = args
3059 .get("format")
3060 .and_then(|v| v.as_str())
3061 .unwrap_or("json");
3062 let limit = args
3063 .get("limit")
3064 .and_then(serde_json::Value::as_u64)
3065 .unwrap_or(1000) as usize;
3066
3067 let memories = self.store.scan(galaxy, limit)?;
3068
3069 let exported = match format {
3070 "json" => {
3071 let entries: Vec<Value> = memories
3072 .iter()
3073 .map(|m| {
3074 json!({
3075 "id": m.metadata.id,
3076 "content": &m.content,
3077 "tags": &m.metadata.tags,
3078 "importance": m.metadata.importance,
3079 "created_at": m.metadata.created_at.to_rfc3339(),
3080 "access_count": m.metadata.access_count,
3081 })
3082 })
3083 .collect();
3084 serde_json::to_string_pretty(&entries).unwrap_or_default()
3085 }
3086 "csv" => {
3087 let mut csv = String::from("id,content,tags,importance,created_at,access_count\n");
3088 for m in &memories {
3089 let tags = m.metadata.tags.join(";");
3090 let content = m.content.replace('\n', " ").replace('"', "'");
3091 let _ = writeln!(
3092 csv,
3093 "{},{},{},{:.3},{},{}",
3094 m.metadata.id,
3095 content,
3096 tags,
3097 m.metadata.importance,
3098 m.metadata.created_at.to_rfc3339(),
3099 m.metadata.access_count,
3100 );
3101 }
3102 csv
3103 }
3104 "markdown" => {
3105 let mut md = format!("# Memory Export: {}\n\n", galaxy_name(galaxy));
3106 let _ = write!(md, "Total memories: {}\n\n", memories.len());
3107 for m in &memories {
3108 let _ = write!(
3109 md,
3110 "## {}\n\n- **Importance**: {:.2}\n- **Tags**: {}\n- **Created**: {}\n- **Access Count**: {}\n\n{}\n\n---\n\n",
3111 m.metadata.id,
3112 m.metadata.importance,
3113 m.metadata.tags.join(", "),
3114 m.metadata.created_at.to_rfc3339(),
3115 m.metadata.access_count,
3116 m.content,
3117 );
3118 }
3119 md
3120 }
3121 _ => {
3122 return Err(wm_core::CoreError::InvalidArgs(format!(
3123 "Unknown format: '{format}'. Use json, csv, or markdown"
3124 )));
3125 }
3126 };
3127
3128 Ok(json!({
3129 "status": "success",
3130 "galaxy": galaxy_name(galaxy),
3131 "format": format,
3132 "count": memories.len(),
3133 "export": exported,
3134 }))
3135 }
3136 fn stats(&self) -> &ToolStats {
3137 &self.stats
3138 }
3139}
3140
3141#[cfg(test)]
3142mod tests {
3143 use super::*;
3144 use wm_core::{EpisodicKind, EpisodicRecord, Galaxy, Provenance, ProvenanceSource};
3145 use wm_memory::{Association, AssociationStore, LinkType, Memory, MemoryStore};
3146
3147 fn test_store() -> Arc<MemoryStore> {
3148 let dir = tempfile::tempdir().unwrap();
3149 Arc::new(MemoryStore::open_default(dir.path()).unwrap())
3150 }
3151
3152 fn populate_memories(store: &Arc<MemoryStore>, galaxy: Galaxy) {
3153 let mut m1 = Memory::new(galaxy, "First memory about rust".into());
3154 m1.metadata.importance = 0.9;
3155 m1.metadata.tags = vec!["rust".into(), "programming".into()];
3156 let _ = store.put(galaxy, &m1);
3157
3158 let mut m2 = Memory::new(galaxy, "Second memory about python".into());
3159 m2.metadata.importance = 0.5;
3160 m2.metadata.tags = vec!["python".into()];
3161 let _ = store.put(galaxy, &m2);
3162
3163 let mut m3 = Memory::new(galaxy, "Third memory about rust".into());
3164 m3.metadata.importance = 0.3;
3165 m3.metadata.tags = vec!["rust".into(), "tutorial".into()];
3166 let _ = store.put(galaxy, &m3);
3167 }
3168
3169 #[tokio::test]
3170 async fn episodic_search_filters_private_records() {
3171 let store = test_store();
3172 let public = EpisodicRecord::new(
3173 None,
3174 1,
3175 EpisodicKind::Observation,
3176 "public retrieval evidence",
3177 Provenance::new(ProvenanceSource::User),
3178 );
3179 let private = EpisodicRecord::new(
3180 None,
3181 2,
3182 EpisodicKind::Observation,
3183 "private retrieval evidence",
3184 Provenance::new(ProvenanceSource::User),
3185 )
3186 .with_visibility(true, false);
3187 store.episodic().append(&public).unwrap();
3188 store.episodic().append(&private).unwrap();
3189
3190 let tool = MemoryEpisodicSearchTool::new(store);
3191 let mut ctx = Context::default();
3192 let result = tool
3193 .call(
3194 &mut ctx,
3195 json!({"query": "retrieval evidence", "limit": 10}),
3196 )
3197 .await
3198 .unwrap();
3199 assert_eq!(result["count"], 1);
3200 assert_eq!(result["results"][0]["id"], json!(public.id));
3201 }
3202
3203 fn mirror_memory(
3206 store: &Arc<MemoryStore>,
3207 mem: &Memory,
3208 session: Option<uuid::Uuid>,
3209 sequence: u64,
3210 ) {
3211 use wm_core::EpisodicCapturePolicy;
3212 let record = EpisodicRecord::new(
3213 session,
3214 sequence,
3215 EpisodicKind::Observation,
3216 mem.content.clone(),
3217 Provenance::new(ProvenanceSource::User),
3218 )
3219 .with_id(mem.metadata.id)
3220 .with_visibility(mem.metadata.is_private, mem.metadata.model_exclude);
3221 store
3222 .episodic()
3223 .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
3224 .unwrap();
3225 }
3226
3227 fn default_search_tool(
3228 store: Arc<MemoryStore>,
3229 search: Option<Arc<SearchEngine>>,
3230 ) -> MemoryHybridRecallTool {
3231 MemoryHybridRecallTool::as_search(store, search, None)
3232 }
3233
3234 #[tokio::test]
3235 async fn default_route_prefers_episodic_and_discloses_mode() {
3236 let (_dir, store, search) = hybrid_fixture();
3240 let needle = Memory::new(
3241 Galaxy::Codex,
3242 "Kotlin coroutine budget meeting notes".into(),
3243 );
3244 let needle_id = needle.metadata.id;
3245 let other = Memory::new(Galaxy::Codex, "Grocery list eggs and flour".into());
3246 store.put(Galaxy::Codex, &needle).unwrap();
3247 store.put(Galaxy::Codex, &other).unwrap();
3248 mirror_memory(&store, &needle, None, 1);
3249 mirror_memory(&store, &other, None, 2);
3250
3251 let tool = default_search_tool(store.clone(), Some(search));
3252 let mut ctx = Context::default();
3253 let v = tool
3254 .call(
3255 &mut ctx,
3256 json!({"query": "kotlin coroutine budget", "limit": 10}),
3257 )
3258 .await
3259 .unwrap();
3260 assert_eq!(v["recall_mode"], "episodic");
3261 assert_eq!(v["results"][0]["source"], "episodic");
3262 assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
3263 assert!(v["results"][0]["score"].as_f64().unwrap() > 0.0);
3264 }
3265
3266 #[test]
3267 fn fused_mode_label_follows_the_configured_vector_weight() {
3268 assert_eq!(fused_mode_label(0.3), "hybrid");
3270 assert_eq!(fused_mode_label(1.0), "hybrid");
3271 assert_eq!(fused_mode_label(0.0), "bm25");
3272 }
3273
3274 #[tokio::test]
3275 async fn zero_vector_weight_discloses_bm25_not_hybrid() {
3276 let (_dir, store, search) = hybrid_fixture();
3281 let needle = Memory::new(
3282 Galaxy::Codex,
3283 "Kotlin coroutine budget meeting notes".into(),
3284 );
3285 let needle_id = needle.metadata.id;
3286 store.put(Galaxy::Codex, &needle).unwrap();
3287 wm_memory::reindex::rebuild_index(&store, &search, &[]).unwrap();
3288
3289 struct NoEmbedEmbedder;
3294 impl wm_memory::Embedder for NoEmbedEmbedder {
3295 fn embed_batch(&self, _texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
3296 panic!("zeroed vector weight must not call the embedder");
3297 }
3298 fn dimension(&self) -> usize {
3299 16
3300 }
3301 fn is_available(&self) -> bool {
3302 true
3303 }
3304 fn backend_name(&self) -> &'static str {
3305 "no-embed-test"
3306 }
3307 }
3308
3309 let config = wm_memory::RecallConfig {
3310 bm25_weight: 1.0,
3311 vector_weight: 0.0,
3312 importance_weight: 0.0,
3313 ..wm_memory::RecallConfig::default()
3314 };
3315 let recall = Arc::new(
3316 RecallEngine::new(
3317 store.clone(),
3318 search.clone(),
3319 wm_memory::VectorStore::new(),
3320 Arc::new(NoEmbedEmbedder),
3321 config,
3322 )
3323 .unwrap(),
3324 );
3325 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), Some(recall));
3326 let mut ctx = Context::default();
3327 let v = tool
3328 .call(
3329 &mut ctx,
3330 json!({"query": "kotlin coroutine budget", "limit": 10}),
3331 )
3332 .await
3333 .unwrap();
3334 assert_eq!(v["recall_mode"], "bm25", "{v}");
3335 assert_eq!(v["results"][0]["source"], "bm25", "{v}");
3336 assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
3337 }
3338
3339 #[tokio::test]
3340 async fn search_rejects_non_positive_limit() {
3341 let (_dir, store, search) = hybrid_fixture();
3346 index_memory(&store, &search, Galaxy::Codex, "kotlin coroutine budget");
3347 let tool = MemoryHybridRecallTool::as_search(store, Some(search), None);
3348 let mut ctx = Context::default();
3349 for bad in [json!(0), json!(-1), json!("0"), json!(0.5)] {
3350 let err = tool
3351 .call(&mut ctx, json!({"query": "kotlin", "limit": bad}))
3352 .await
3353 .unwrap_err();
3354 assert!(
3355 err.to_string().contains("limit"),
3356 "limit={bad} must be rejected, got: {err}"
3357 );
3358 }
3359 let v = tool
3360 .call(&mut ctx, json!({"query": "kotlin", "limit": 1}))
3361 .await
3362 .unwrap();
3363 assert_eq!(v["status"], "success", "{v}");
3364 assert_eq!(v["results"].as_array().map(Vec::len), Some(1), "{v}");
3365 }
3366
3367 #[tokio::test]
3368 async fn default_route_matches_the_episodic_machinery_ranking() {
3369 let (_dir, store, search) = hybrid_fixture();
3373 let contents = [
3374 "Deployed the telemetry agent on Tuesday",
3375 "Cancun trip booked for the twelfth",
3376 "Telemetry agent rollout postponed to Friday",
3377 "Deadline for the quarterly report moved",
3378 ];
3379 let mut memories: Vec<(uuid::Uuid, &str)> = Vec::new();
3380 for (i, content) in contents.iter().enumerate() {
3381 let mem = Memory::new(Galaxy::Codex, (*content).to_string());
3382 memories.push((mem.metadata.id, content));
3383 store.put(Galaxy::Codex, &mem).unwrap();
3384 mirror_memory(&store, &mem, None, i as u64 + 1);
3385 }
3386 let query = "when was the telemetry agent deployed";
3387
3388 let default_tool = default_search_tool(store.clone(), Some(search.clone()));
3389 let mut ctx = Context::default();
3390 let default_v = default_tool
3391 .call(&mut ctx, json!({"query": query, "limit": 10}))
3392 .await
3393 .unwrap();
3394 let episodic_tool = MemoryEpisodicSearchTool::new(store);
3395 let episodic_v = episodic_tool
3396 .call(&mut ctx, json!({"query": query, "limit": 10}))
3397 .await
3398 .unwrap();
3399 assert_eq!(
3400 default_v["results"][0]["id"], episodic_v["results"][0]["id"],
3401 "default route must rank exactly like the episodic machinery"
3402 );
3403 let top = memories
3404 .iter()
3405 .find(|(id, _)| id.to_string() == default_v["results"][0]["id"])
3406 .map(|(_, c)| *c)
3407 .unwrap();
3408 assert_eq!(top, "Deployed the telemetry agent on Tuesday");
3409 }
3410
3411 #[tokio::test]
3412 async fn default_route_falls_back_to_fts_when_episodic_yields_nothing() {
3413 let (_dir, store, search) = hybrid_fixture();
3416 let mem = Memory::new(Galaxy::Codex, "Zebra quotas revised upward".into());
3417 let id = mem.metadata.id;
3418 store.put(Galaxy::Codex, &mem).unwrap();
3419 {
3420 let mut writer = search.writer().unwrap();
3421 search
3422 .add_document(
3423 &mut writer,
3424 &id.to_string(),
3425 "codex",
3426 "Zebra quotas revised upward",
3427 &mem.metadata.tags,
3428 mem.metadata.created_at.timestamp(),
3429 )
3430 .unwrap();
3431 search.commit(&mut writer).unwrap();
3432 }
3433
3434 let tool = default_search_tool(store, Some(search));
3435 let mut ctx = Context::default();
3436 let v = tool
3437 .call(&mut ctx, json!({"query": "zebra quotas", "limit": 10}))
3438 .await
3439 .unwrap();
3440 assert_eq!(v["recall_mode"], "fts");
3441 assert_eq!(v["results"][0]["source"], "fts");
3442 assert_eq!(v["results"][0]["id"], json!(id.to_string()));
3443 }
3444
3445 #[tokio::test]
3446 async fn episodic_default_route_respects_galaxy_filter() {
3447 let (_dir, store, search) = hybrid_fixture();
3448 let in_galaxy = Memory::new(Galaxy::Codex, "Marble fountain restoration plan".into());
3449 let other_galaxy =
3450 Memory::new(Galaxy::Sessions, "Marble fountain restoration notes".into());
3451 store.put(Galaxy::Codex, &in_galaxy).unwrap();
3452 store.put(Galaxy::Sessions, &other_galaxy).unwrap();
3453 mirror_memory(&store, &in_galaxy, None, 1);
3454 mirror_memory(&store, &other_galaxy, None, 2);
3455
3456 let tool = default_search_tool(store, Some(search));
3457 let mut ctx = Context::default();
3458 let v = tool
3459 .call(
3460 &mut ctx,
3461 json!({"query": "marble fountain", "galaxy": "sessions", "limit": 10}),
3462 )
3463 .await
3464 .unwrap();
3465 assert_eq!(v["recall_mode"], "episodic");
3466 for r in v["results"].as_array().unwrap() {
3467 assert_eq!(r["galaxy"], "sessions", "galaxy filter must hold");
3468 }
3469 assert_eq!(
3470 v["results"][0]["id"],
3471 json!(other_galaxy.metadata.id.to_string())
3472 );
3473 }
3474
3475 #[tokio::test]
3476 async fn episodic_default_route_filters_private_and_stale() {
3477 let (_dir, store, search) = hybrid_fixture();
3478 let public = Memory::new(
3479 Galaxy::Codex,
3480 "Lighthouse maintenance schedule confirmed".into(),
3481 );
3482 let mut private = Memory::new(Galaxy::Codex, "Lighthouse access code renewal".into());
3483 private.metadata.is_private = true;
3484 let stale = Memory::new(Galaxy::Codex, "Lighthouse inspection legacy draft".into());
3485 let stale_id = stale.metadata.id;
3486 store.put(Galaxy::Codex, &public).unwrap();
3487 store.put(Galaxy::Codex, &private).unwrap();
3488 store.put(Galaxy::Codex, &stale).unwrap();
3489 mirror_memory(&store, &public, None, 1);
3490 mirror_memory(&store, &private, None, 2);
3491 mirror_memory(&store, &stale, None, 3);
3492 store.delete(Galaxy::Codex, stale_id).unwrap();
3495
3496 let tool = default_search_tool(store, Some(search));
3497 let mut ctx = Context::default();
3498 let v = tool
3499 .call(&mut ctx, json!({"query": "lighthouse", "limit": 10}))
3500 .await
3501 .unwrap();
3502 assert_eq!(v["recall_mode"], "episodic");
3503 let ids: Vec<&str> = v["results"]
3504 .as_array()
3505 .unwrap()
3506 .iter()
3507 .filter_map(|r| r["id"].as_str())
3508 .collect();
3509 assert!(
3510 !ids.contains(&private.metadata.id.to_string().as_str()),
3511 "private memories must never surface on the default route"
3512 );
3513 assert!(
3514 !ids.contains(&stale_id.to_string().as_str()),
3515 "episodic records without a live v5 memory must be skipped"
3516 );
3517 assert!(!ids.is_empty(), "the public hit must still surface");
3518 }
3519
3520 #[tokio::test]
3521 async fn memory_sort_by_importance_desc() {
3522 let store = test_store();
3523 populate_memories(&store, Galaxy::Codex);
3524 let tool = MemorySortTool::new(store);
3525 let mut ctx = Context::default();
3526 let v = tool
3527 .call(&mut ctx, json!({"sort_by": "importance", "order": "desc"}))
3528 .await
3529 .unwrap();
3530 assert_eq!(v["status"], "success");
3531 assert_eq!(v["returned"], 3);
3532 let mems = v["memories"].as_array().unwrap();
3533 assert!(mems[0]["importance"].as_f64().unwrap() >= mems[1]["importance"].as_f64().unwrap());
3534 }
3535
3536 #[tokio::test]
3537 async fn memory_update_cannot_mutate_tier() {
3538 let store = test_store();
3542 let mem = Memory::new(Galaxy::Codex, "tier is not client-settable".into());
3543 let id = mem.metadata.id;
3544 store.put(Galaxy::Codex, &mem).unwrap();
3545
3546 let tool = MemoryUpdateTool::new(store.clone(), None);
3547 let mut ctx = Context::default();
3548 let v = tool
3549 .call(
3550 &mut ctx,
3551 json!({"galaxy": "codex", "id": id.to_string(), "tier": "archival", "tags": ["x"]}),
3552 )
3553 .await
3554 .unwrap();
3555 assert_eq!(v["status"], "success");
3556
3557 let after = store.get(Galaxy::Codex, id).unwrap().unwrap();
3558 assert_eq!(
3559 after.metadata.tier,
3560 wm_memory::Tier::Working,
3561 "memory.update must never move the tier"
3562 );
3563 assert_eq!(
3564 after.metadata.tags,
3565 vec!["x".to_string()],
3566 "whitelisted fields still apply"
3567 );
3568 }
3569
3570 #[tokio::test]
3571 async fn empty_search_hints_at_populated_galaxies() {
3572 let (_dir, store, search) = hybrid_fixture();
3577 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3578 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3579 let mut ctx = Context::default();
3580 let v = tool
3581 .call(&mut ctx, json!({"query": "gate plan"}))
3582 .await
3583 .unwrap();
3584 assert_eq!(
3585 v["count"], 1,
3586 "unfiltered search must find cross-galaxy content: {v}"
3587 );
3588 assert_eq!(v["galaxy"], "all");
3589 assert_eq!(v["results"][0]["galaxy"], "sessions");
3590 assert!(v["hint"].is_null());
3591
3592 let v2 = tool
3594 .call(
3595 &mut ctx,
3596 json!({"query": "zzz-no-match", "galaxy": "sessions"}),
3597 )
3598 .await
3599 .unwrap();
3600 assert_eq!(v2["count"], 0);
3601 let hint2 = v2["hint"].as_str().unwrap();
3602 assert!(hint2.contains("no matches for this query"), "{hint2}");
3603
3604 let v3 = tool
3607 .call(&mut ctx, json!({"query": "zzz-no-match"}))
3608 .await
3609 .unwrap();
3610 assert_eq!(v3["count"], 0);
3611 let hint3 = v3["hint"].as_str().expect("hint present on empty result");
3612 assert!(hint3.contains("across all memory galaxies"), "{hint3}");
3613 }
3614
3615 #[tokio::test]
3616 async fn unfiltered_search_labels_hits_from_every_galaxy() {
3617 let (_dir, store, search) = hybrid_fixture();
3622 index_memory(
3623 &store,
3624 &search,
3625 Galaxy::Sessions,
3626 "lineage ledger phase four",
3627 );
3628 index_memory(
3629 &store,
3630 &search,
3631 Galaxy::Codex,
3632 "lineage ledger codex mirror note",
3633 );
3634 index_memory(&store, &search, Galaxy::Dreams, "lineage ledger dream echo");
3635 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
3636 let mut ctx = Context::default();
3637 let v = tool
3638 .call(&mut ctx, json!({"query": "lineage ledger", "limit": 10}))
3639 .await
3640 .unwrap();
3641 assert_eq!(v["count"], 3, "got: {v}");
3642 let galaxies: Vec<&str> = v["results"]
3643 .as_array()
3644 .unwrap()
3645 .iter()
3646 .map(|r| r["galaxy"].as_str().unwrap())
3647 .collect();
3648 assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
3649 assert!(galaxies.contains(&"codex"), "got: {galaxies:?}");
3650 assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
3651
3652 let v2 = tool
3654 .call(
3655 &mut ctx,
3656 json!({"query": "lineage ledger", "galaxy": "dreams"}),
3657 )
3658 .await
3659 .unwrap();
3660 assert_eq!(v2["count"], 1, "got: {v2}");
3661 assert_eq!(v2["results"][0]["galaxy"], "dreams");
3662 }
3663
3664 #[tokio::test]
3665 async fn galaxy_all_alias_matches_unfiltered_search() {
3666 let (_dir, store, search) = hybrid_fixture();
3670 index_memory(&store, &search, Galaxy::Sessions, "alias probe session");
3671 index_memory(&store, &search, Galaxy::Dreams, "alias probe dream");
3672 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3673 let mut ctx = Context::default();
3674 let v = tool
3675 .call(
3676 &mut ctx,
3677 json!({"query": "alias probe", "galaxy": "all", "limit": 10}),
3678 )
3679 .await
3680 .unwrap();
3681 assert_eq!(v["count"], 2, "got: {v}");
3682 assert_eq!(v["galaxy"], "all");
3683 let galaxies: Vec<&str> = v["results"]
3684 .as_array()
3685 .unwrap()
3686 .iter()
3687 .map(|r| r["galaxy"].as_str().unwrap())
3688 .collect();
3689 assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
3690 assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
3691
3692 let v2 = tool
3694 .call(
3695 &mut ctx,
3696 json!({"query": "alias probe", "galaxy": "ALL", "limit": 10}),
3697 )
3698 .await
3699 .unwrap();
3700 assert_eq!(v2["count"], 2, "got: {v2}");
3701 }
3702
3703 #[tokio::test]
3704 async fn unfiltered_search_excludes_telemetry_unless_explicit() {
3705 let (_dir, store, search) = hybrid_fixture();
3710 index_memory(
3711 &store,
3712 &search,
3713 Galaxy::Codex,
3714 "telemetry probe project note",
3715 );
3716 index_memory(
3717 &store,
3718 &search,
3719 Galaxy::Telemetry,
3720 "telemetry probe diagnostic record",
3721 );
3722 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3723 let mut ctx = Context::default();
3724 let v = tool
3725 .call(&mut ctx, json!({"query": "telemetry probe", "limit": 10}))
3726 .await
3727 .unwrap();
3728 assert_eq!(v["count"], 1, "unfiltered search must skip telemetry: {v}");
3729 assert_eq!(v["results"][0]["galaxy"], "codex");
3730
3731 let v2 = tool
3732 .call(
3733 &mut ctx,
3734 json!({"query": "telemetry probe", "galaxy": "telemetry", "limit": 10}),
3735 )
3736 .await
3737 .unwrap();
3738 assert_eq!(v2["count"], 1, "explicit telemetry must still work: {v2}");
3739 assert_eq!(v2["results"][0]["galaxy"], "telemetry");
3740 }
3741
3742 #[tokio::test]
3743 async fn successful_search_carries_no_hint() {
3744 let (_dir, store, search) = hybrid_fixture();
3745 index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
3746 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3747 let mut ctx = Context::default();
3748 let v = tool
3749 .call(
3750 &mut ctx,
3751 json!({"query": "gate plan", "galaxy": "sessions"}),
3752 )
3753 .await
3754 .unwrap();
3755 assert_eq!(v["count"], 1);
3756 assert!(v["hint"].is_null());
3757 }
3758
3759 #[tokio::test]
3760 async fn associative_expansion_surfaces_linked_memory() {
3761 let dir = tempfile::tempdir().unwrap();
3765 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3766 let tantivy_dir = dir.path().join("tantivy");
3767 std::fs::create_dir_all(&tantivy_dir).unwrap();
3768 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3769 index_memory(
3770 &store,
3771 &search,
3772 Galaxy::Codex,
3773 "gate plan for the v7 alpha release",
3774 );
3775 let mut linked = Memory::new(
3776 Galaxy::Codex,
3777 "backup automation runs nightly at 03:30".into(),
3778 );
3779 linked.metadata.importance = 0.7;
3780 let linked_id = linked.metadata.id;
3781 store.put(Galaxy::Codex, &linked).unwrap();
3782 search
3783 .writer()
3784 .and_then(|mut w| {
3785 search.add_document(
3786 &mut w,
3787 &linked_id.to_string(),
3788 "codex",
3789 &linked.content,
3790 &linked.metadata.tags,
3791 linked.metadata.created_at.timestamp(),
3792 )?;
3793 search.commit(&mut w)
3794 })
3795 .unwrap();
3796
3797 let assoc = Association::new(
3799 find_id(&store, "gate plan"),
3800 linked_id,
3801 LinkType::Extends,
3802 0.8,
3803 );
3804 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3805 associations.put(store.env(), &assoc).unwrap();
3806
3807 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3808 .with_associations(Some(associations));
3809 let mut ctx = Context::default();
3810 let v = tool
3811 .call(&mut ctx, json!({"query": "gate plan alpha release"}))
3812 .await
3813 .unwrap();
3814 assert_eq!(v["count"], 2, "direct hit + associated memory: {v}");
3815 let assoc_hit = v["results"]
3816 .as_array()
3817 .unwrap()
3818 .iter()
3819 .find(|r| r["source"] == "association")
3820 .expect("association-sourced result present");
3821 assert_eq!(assoc_hit["id"], json!(linked_id.to_string()));
3822 assert_eq!(assoc_hit["link_type"], "extends");
3823 assert!(assoc_hit["via"].is_string());
3824 assert!(assoc_hit["weight"].as_f64().unwrap() > 0.7);
3825 }
3826
3827 fn find_id(store: &MemoryStore, needle: &str) -> uuid::Uuid {
3828 store
3829 .scan(Galaxy::Codex, 100)
3830 .unwrap()
3831 .into_iter()
3832 .find(|m| m.content.contains(needle))
3833 .map(|m| m.metadata.id)
3834 .unwrap()
3835 }
3836
3837 #[tokio::test]
3838 async fn associative_expansion_skips_private_and_dedupes() {
3839 let dir = tempfile::tempdir().unwrap();
3840 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3841 let mut a = Memory::new(Galaxy::Codex, "quarterly revenue planning notes".into());
3842 a.metadata.importance = 0.8;
3843 let a_id = a.metadata.id;
3844 store.put(Galaxy::Codex, &a).unwrap();
3845 let mut private = Memory::new(Galaxy::Codex, "private salary bands".into());
3847 private.metadata.is_private = true;
3848 private.metadata.importance = 0.8;
3849 store.put(Galaxy::Codex, &private).unwrap();
3850 let mut b = Memory::new(Galaxy::Codex, "hiring plan for next quarter".into());
3852 b.metadata.importance = 0.7;
3853 let b_id = b.metadata.id;
3854 store.put(Galaxy::Codex, &b).unwrap();
3855
3856 let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3857 associations
3858 .put(
3859 store.env(),
3860 &Association::new(a_id, private.metadata.id, LinkType::Related, 0.9),
3861 )
3862 .unwrap();
3863 associations
3864 .put(
3865 store.env(),
3866 &Association::new(a_id, b_id, LinkType::Related, 0.9),
3867 )
3868 .unwrap();
3869 associations
3870 .put(
3871 store.env(),
3872 &Association::new(b_id, a_id, LinkType::Related, 0.9),
3873 )
3874 .unwrap();
3875
3876 let tool = MemoryHybridRecallTool::as_search(store.clone(), None, None)
3877 .with_associations(Some(associations.clone()));
3878 let mut ctx = Context::default();
3879 let _ = &tool;
3883 let tantivy_dir = dir.path().join("tantivy");
3884 std::fs::create_dir_all(&tantivy_dir).unwrap();
3885 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3886 for (content, id) in [
3887 ("quarterly revenue planning notes", a_id),
3888 ("private salary bands", private.metadata.id),
3889 ("hiring plan for next quarter", b_id),
3890 ] {
3891 search
3892 .writer()
3893 .and_then(|mut w| {
3894 search.add_document(&mut w, &id.to_string(), "codex", content, &[], 0)?;
3895 search.commit(&mut w)
3896 })
3897 .unwrap();
3898 }
3899 let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
3900 .with_associations(Some(associations));
3901 let v = tool
3902 .call(&mut ctx, json!({"query": "quarterly revenue planning"}))
3903 .await
3904 .unwrap();
3905 let ids: Vec<&str> = v["results"]
3906 .as_array()
3907 .unwrap()
3908 .iter()
3909 .filter_map(|r| r["id"].as_str())
3910 .collect();
3911 assert!(
3912 !ids.iter().any(|id| *id == private.metadata.id.to_string()),
3913 "private memory must not surface via association: {ids:?}"
3914 );
3915 assert_eq!(
3916 ids.iter().filter(|id| **id == b_id.to_string()).count(),
3917 1,
3918 "neighbor linked both directions appears exactly once: {ids:?}"
3919 );
3920 }
3921
3922 #[tokio::test]
3923 async fn memory_update_content_recomputes_hash() {
3924 let store = test_store();
3925 let mem = Memory::new(Galaxy::Codex, "original text".into());
3926 store.put(Galaxy::Codex, &mem).unwrap();
3927 let id = mem.metadata.id;
3928 let original_hash = mem.metadata.content_hash.clone();
3929
3930 let tool = MemoryUpdateTool::new(store.clone(), None);
3931 let v = tool
3932 .call(
3933 &mut Context::default(),
3934 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3935 )
3936 .await
3937 .unwrap();
3938 assert_eq!(v["status"], "success");
3939
3940 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3943 assert_eq!(stored.content, "changed text");
3944 assert_eq!(
3945 stored.metadata.content_hash,
3946 wm_memory::content_hash("changed text")
3947 );
3948 assert_ne!(stored.metadata.content_hash, original_hash);
3949 }
3950
3951 #[tokio::test]
3952 async fn memory_update_discloses_hash_timeline() {
3953 let store = test_store();
3957 let mem = Memory::new(Galaxy::Codex, "original text".into());
3958 store.put(Galaxy::Codex, &mem).unwrap();
3959 let id = mem.metadata.id;
3960 let original_hash = mem.metadata.content_hash.clone();
3961 let tool = MemoryUpdateTool::new(store.clone(), None);
3962 let mut ctx = Context::default();
3963
3964 let v = tool
3965 .call(
3966 &mut ctx,
3967 json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3968 )
3969 .await
3970 .unwrap();
3971 assert_eq!(
3972 v["content_hash"],
3973 json!(wm_memory::content_hash("changed text"))
3974 );
3975 assert_eq!(v["prev_content_hash"], json!(original_hash));
3976
3977 let v = tool
3979 .call(
3980 &mut ctx,
3981 json!({"galaxy": "codex", "id": id.to_string(), "tags": ["amended"]}),
3982 )
3983 .await
3984 .unwrap();
3985 assert_eq!(
3986 v["content_hash"],
3987 json!(wm_memory::content_hash("changed text"))
3988 );
3989 assert!(v.get("prev_content_hash").is_none());
3990 }
3991
3992 #[tokio::test]
3993 async fn memory_update_appends_revision_chain() {
3994 let store = test_store();
3997 let mem = Memory::new(Galaxy::Codex, "original text".into());
3998 store.put(Galaxy::Codex, &mem).unwrap();
3999 let id = mem.metadata.id;
4000 let tool = MemoryUpdateTool::new(store.clone(), None);
4001 let mut ctx = Context {
4002 user_id: Some("agent-b".to_string()),
4003 session_id: Some(uuid::Uuid::nil()),
4004 compartment: Some("production".to_string()),
4005 ..Default::default()
4006 };
4007 let v = tool
4008 .call(
4009 &mut ctx,
4010 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
4011 )
4012 .await
4013 .unwrap();
4014 assert_eq!(v["revision"]["seq"], 0);
4015 assert_eq!(
4016 v["revision"]["old_hash"],
4017 json!(wm_memory::content_hash("original text"))
4018 );
4019 assert_eq!(
4020 v["revision"]["new_hash"],
4021 json!(wm_memory::content_hash("second text"))
4022 );
4023
4024 let v = tool
4025 .call(
4026 &mut ctx,
4027 json!({"galaxy": "codex", "id": id.to_string(), "content": "third text"}),
4028 )
4029 .await
4030 .unwrap();
4031 assert_eq!(v["revision"]["seq"], 1);
4032
4033 let revisions = store.revisions(Galaxy::Codex, id).unwrap();
4034 assert_eq!(revisions.len(), 2);
4035 assert_eq!(revisions[1].old_hash, revisions[0].new_hash, "chain links");
4036 assert_eq!(revisions[0].actor_user.as_deref(), Some("agent-b"));
4037 assert_eq!(
4038 revisions[0].actor_compartment.as_deref(),
4039 Some("production")
4040 );
4041 assert_eq!(
4042 revisions[0].actor_session.as_deref(),
4043 Some(uuid::Uuid::nil().to_string().as_str())
4044 );
4045
4046 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
4047 assert_eq!(stored.metadata.revision_count, 2);
4048
4049 let report = store
4051 .verify_revision_chain(Galaxy::Codex, id, &stored.metadata.content_hash)
4052 .unwrap();
4053 assert!(report.valid, "{:?}", report.breaks);
4054 assert!(report.matches_head);
4055 }
4056
4057 #[tokio::test]
4058 async fn memory_update_out_of_band_edit_breaks_chain() {
4059 let store = test_store();
4062 let mem = Memory::new(Galaxy::Codex, "original text".into());
4063 store.put(Galaxy::Codex, &mem).unwrap();
4064 let id = mem.metadata.id;
4065 let tool = MemoryUpdateTool::new(store.clone(), None);
4066 tool.call(
4067 &mut Context::default(),
4068 json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
4069 )
4070 .await
4071 .unwrap();
4072
4073 let mut row = store.get(Galaxy::Codex, id).unwrap().unwrap();
4075 row.content = "smuggled text".to_string();
4076 row.metadata.content_hash = wm_memory::content_hash("smuggled text");
4077 store.put(Galaxy::Codex, &row).unwrap();
4078
4079 let report = store
4080 .verify_revision_chain(Galaxy::Codex, id, &row.metadata.content_hash)
4081 .unwrap();
4082 assert!(!report.valid);
4083 assert!(!report.matches_head);
4084 assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
4085 }
4086
4087 #[tokio::test]
4088 async fn memory_revisions_tool_list_and_verify() {
4089 let store = test_store();
4090 let mem = Memory::new(Galaxy::Codex, "v1".into());
4091 store.put(Galaxy::Codex, &mem).unwrap();
4092 let id = mem.metadata.id;
4093 let update = MemoryUpdateTool::new(store.clone(), None);
4094 update
4095 .call(
4096 &mut Context::default(),
4097 json!({"galaxy": "codex", "id": id.to_string(), "content": "v2"}),
4098 )
4099 .await
4100 .unwrap();
4101
4102 let tool = MemoryRevisionsTool::new(store.clone());
4103 let v = tool
4104 .call(&mut Context::default(), json!({"id": id.to_string()}))
4105 .await
4106 .unwrap();
4107 assert_eq!(v["action"], "list");
4108 assert_eq!(v["count"], 1);
4109
4110 let v = tool
4111 .call(
4112 &mut Context::default(),
4113 json!({"id": id.to_string(), "action": "verify"}),
4114 )
4115 .await
4116 .unwrap();
4117 assert_eq!(v["valid"], true);
4118 assert_eq!(v["entries"], 1);
4119
4120 store
4123 .record_revision(
4124 Galaxy::Codex,
4125 id,
4126 "forged_old_hash",
4127 &wm_memory::content_hash("v2"),
4128 wm_memory::RevisionActor::default(),
4129 )
4130 .unwrap();
4131 let v = tool
4132 .call(
4133 &mut Context::default(),
4134 json!({"id": id.to_string(), "action": "verify"}),
4135 )
4136 .await
4137 .unwrap();
4138 assert_eq!(v["valid"], false);
4139 let breaks: Vec<String> = v["breaks"]
4140 .as_array()
4141 .unwrap()
4142 .iter()
4143 .map(|b| b.as_str().unwrap().to_string())
4144 .collect();
4145 assert!(
4146 breaks.iter().any(|b| b.contains("hash-linkage")),
4147 "{breaks:?}"
4148 );
4149 }
4150
4151 #[tokio::test]
4152 async fn memory_update_applies_importance_verbatim() {
4153 let store = test_store();
4159
4160 let tel = Memory::new(
4161 Galaxy::Codex,
4162 "## Auto-logged Friction: dispatch error\n\nbody".into(),
4163 );
4164 store.put(Galaxy::Codex, &tel).unwrap();
4165
4166 let tool = MemoryUpdateTool::new(store.clone(), None);
4167 let mut ctx = Context::default();
4168
4169 let v = tool
4170 .call(
4171 &mut ctx,
4172 json!({"galaxy": "codex", "id": tel.metadata.id.to_string(), "importance": 0.9}),
4173 )
4174 .await
4175 .unwrap();
4176 assert!(v.get("class_policy").is_none());
4177 assert!(v.get("write_gate").is_none());
4178 let stored = store.get(Galaxy::Codex, tel.metadata.id).unwrap().unwrap();
4179 assert!((stored.metadata.importance - 0.9).abs() < 1e-5);
4180 }
4181
4182 #[tokio::test]
4183 async fn memory_search_min_trust_filter_drops_low_trust() {
4184 let (_dir, store, search) = hybrid_fixture();
4188 let mut confirmed = Memory::new(Galaxy::Codex, "Quantum foal registry minutes".into());
4189 confirmed.metadata.source_trust = 1.0;
4190 confirmed.metadata.source = "user".to_string();
4191 let mut ingested = Memory::new(Galaxy::Codex, "Quantum foal registry draft".into());
4192 ingested.metadata.source_trust = 0.7;
4193 ingested.metadata.source = "tool".to_string();
4194 store.put(Galaxy::Codex, &confirmed).unwrap();
4195 store.put(Galaxy::Codex, &ingested).unwrap();
4196 mirror_memory(&store, &confirmed, None, 1);
4197 mirror_memory(&store, &ingested, None, 2);
4198
4199 let tool = default_search_tool(store, Some(search));
4200 let mut ctx = Context::default();
4201
4202 let v = tool
4203 .call(
4204 &mut ctx,
4205 json!({"query": "quantum foal registry", "limit": 10}),
4206 )
4207 .await
4208 .unwrap();
4209 assert_eq!(v["count"], 2, "no floor: both results surface");
4210 assert!(v.get("min_trust").is_none());
4211
4212 let v = tool
4213 .call(
4214 &mut ctx,
4215 json!({"query": "quantum foal registry", "limit": 10, "min_trust": 0.9}),
4216 )
4217 .await
4218 .unwrap();
4219 assert_eq!(v["min_trust"], 0.9);
4220 assert_eq!(v["min_trust_filtered"], 1);
4221 let trusts: Vec<f64> = v["results"]
4222 .as_array()
4223 .unwrap()
4224 .iter()
4225 .map(|r| r["trust"].as_f64().unwrap())
4226 .collect();
4227 assert!(trusts.iter().all(|t| *t >= 0.9), "{trusts:?}");
4228 }
4229
4230 #[tokio::test]
4231 async fn memory_sort_by_importance_asc() {
4232 let store = test_store();
4233 populate_memories(&store, Galaxy::Codex);
4234 let tool = MemorySortTool::new(store);
4235 let mut ctx = Context::default();
4236 let v = tool
4237 .call(&mut ctx, json!({"sort_by": "importance", "order": "asc"}))
4238 .await
4239 .unwrap();
4240 let mems = v["memories"].as_array().unwrap();
4241 assert!(mems[0]["importance"].as_f64().unwrap() <= mems[1]["importance"].as_f64().unwrap());
4242 }
4243
4244 #[tokio::test]
4245 async fn memory_sort_by_recency() {
4246 let store = test_store();
4247 populate_memories(&store, Galaxy::Codex);
4248 let tool = MemorySortTool::new(store);
4249 let mut ctx = Context::default();
4250 let v = tool
4251 .call(&mut ctx, json!({"sort_by": "recency"}))
4252 .await
4253 .unwrap();
4254 assert_eq!(v["returned"], 3);
4255 }
4256
4257 #[tokio::test]
4258 async fn memory_sort_invalid_field() {
4259 let store = test_store();
4260 let tool = MemorySortTool::new(store);
4261 let mut ctx = Context::default();
4262 let result = tool.call(&mut ctx, json!({"sort_by": "invalid"})).await;
4263 assert!(result.is_err());
4264 }
4265
4266 #[tokio::test]
4267 async fn memory_sort_with_limit() {
4268 let store = test_store();
4269 populate_memories(&store, Galaxy::Codex);
4270 let tool = MemorySortTool::new(store);
4271 let mut ctx = Context::default();
4272 let v = tool.call(&mut ctx, json!({"limit": 2})).await.unwrap();
4273 assert_eq!(v["returned"], 2);
4274 assert_eq!(v["total"], 3);
4275 }
4276
4277 #[tokio::test]
4278 async fn memory_filter_by_tag() {
4279 let store = test_store();
4280 populate_memories(&store, Galaxy::Codex);
4281 let tool = MemoryFilterTool::new(store);
4282 let mut ctx = Context::default();
4283 let v = tool
4284 .call(&mut ctx, json!({"tags": ["rust"]}))
4285 .await
4286 .unwrap();
4287 assert_eq!(v["matched"], 2);
4288 }
4289
4290 #[tokio::test]
4291 async fn memory_filter_by_importance_range() {
4292 let store = test_store();
4293 populate_memories(&store, Galaxy::Codex);
4294 let tool = MemoryFilterTool::new(store);
4295 let mut ctx = Context::default();
4296 let v = tool
4297 .call(
4298 &mut ctx,
4299 json!({"min_importance": 0.4, "max_importance": 0.6}),
4300 )
4301 .await
4302 .unwrap();
4303 assert_eq!(v["matched"], 1);
4304 }
4305
4306 #[tokio::test]
4310 async fn memory_filter_query_matches_content_and_title() {
4311 let store = test_store();
4312 let tool = MemoryFilterTool::new(store.clone());
4313 let mut ctx = Context::default();
4314
4315 let mut titled = Memory::new(Galaxy::Codex, "unrelated body text".into());
4316 titled.metadata.title = Some("Rust Borrow Checker".into());
4317 let plain = Memory::new(Galaxy::Codex, "rust ownership rules".into());
4318 let other = Memory::new(Galaxy::Codex, "gardening tips".into());
4319 for m in [&titled, &plain, &other] {
4320 store.put(Galaxy::Codex, m).unwrap();
4321 }
4322
4323 let v = tool.call(&mut ctx, json!({"query": "RUST"})).await.unwrap();
4324 assert_eq!(v["matched"], 2, "content hit + title hit: {v}");
4325 assert_eq!(v["filters"]["query_terms"], json!(["rust"]));
4326
4327 let v = tool
4328 .call(&mut ctx, json!({"query": "rust borrow"}))
4329 .await
4330 .unwrap();
4331 assert_eq!(v["matched"], 1, "all terms must match: {v}");
4332 assert_eq!(v["memories"][0]["content"], "unrelated body text");
4333 }
4334
4335 #[tokio::test]
4336 async fn memory_filter_no_matches() {
4337 let store = test_store();
4338 populate_memories(&store, Galaxy::Codex);
4339 let tool = MemoryFilterTool::new(store);
4340 let mut ctx = Context::default();
4341 let v = tool
4342 .call(&mut ctx, json!({"tags": ["nonexistent"]}))
4343 .await
4344 .unwrap();
4345 assert_eq!(v["matched"], 0);
4346 }
4347
4348 #[tokio::test]
4349 async fn memory_filter_combined_tags_and_importance() {
4350 let store = test_store();
4351 populate_memories(&store, Galaxy::Codex);
4352 let tool = MemoryFilterTool::new(store);
4353 let mut ctx = Context::default();
4354 let v = tool
4355 .call(&mut ctx, json!({"tags": ["rust"], "min_importance": 0.5}))
4356 .await
4357 .unwrap();
4358 assert_eq!(v["matched"], 1);
4359 }
4360
4361 #[tokio::test]
4365 async fn memory_filter_offset_exclude_tags_and_date_range() {
4366 let store = test_store();
4367 let tool = MemoryFilterTool::new(store.clone());
4368 let mut ctx = Context::default();
4369
4370 let mut recent_a = Memory::new(Galaxy::Codex, "recent a".into());
4371 recent_a.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(2);
4372 let mut recent_b = Memory::new(Galaxy::Codex, "recent b".into());
4373 recent_b.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
4374 recent_b.metadata.tags = vec!["noise".into()];
4375 let mut recent_priv = Memory::new(Galaxy::Codex, "recent private".into());
4376 recent_priv.metadata.created_at = chrono::Utc::now() - chrono::Duration::minutes(90);
4377 recent_priv.metadata.is_private = true;
4378 let mut old = Memory::new(Galaxy::Codex, "old relic".into());
4379 old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
4380 for m in [&recent_a, &recent_b, &recent_priv, &old] {
4381 store.put(Galaxy::Codex, m).unwrap();
4382 }
4383
4384 let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
4385 .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
4386
4387 let v = tool
4389 .call(
4390 &mut ctx,
4391 json!({
4392 "galaxy": "codex",
4393 "created_after": cutoff,
4394 "exclude_tags": ["noise"],
4395 }),
4396 )
4397 .await
4398 .unwrap();
4399 assert_eq!(v["matched"], 1, "only recent-a is visible in range: {v}");
4400 assert_eq!(v["returned"], 1);
4401 assert_eq!(v["memories"][0]["content"], "recent a");
4402 assert_eq!(v["filters"]["exclude_tags"], json!(["noise"]));
4403 assert!(v["filters"]["created_after"].is_string());
4404
4405 let page2 = tool
4408 .call(
4409 &mut ctx,
4410 json!({"galaxy": "codex", "offset": 3, "limit": 2}),
4411 )
4412 .await
4413 .unwrap();
4414 assert_eq!(
4415 page2["matched"], 3,
4416 "private memory must not count: {page2}"
4417 );
4418 assert_eq!(
4419 page2["returned"], 0,
4420 "offset past the match set is an honest empty page"
4421 );
4422 assert_eq!(page2["offset"], 3);
4423
4424 let bad = tool
4426 .call(
4427 &mut ctx,
4428 json!({"galaxy": "codex", "created_before": "yesterday"}),
4429 )
4430 .await;
4431 assert!(bad.is_err(), "non-RFC-3339 bound must be refused");
4432 }
4433
4434 #[tokio::test]
4435 async fn memory_deduplicate_hash_dry_run() {
4436 let store = test_store();
4437 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
4438 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
4439 let _ = store.put(Galaxy::Codex, &m1);
4440 let _ = store.put(Galaxy::Codex, &m2);
4441 let _ = store.put(
4442 Galaxy::Codex,
4443 &Memory::new(Galaxy::Codex, "unique content".into()),
4444 );
4445
4446 let tool = MemoryDeduplicateTool::new(store.clone(), None);
4447 let mut ctx = Context::default();
4448 let v = tool
4449 .call(&mut ctx, json!({"mode": "hash", "dry_run": true}))
4450 .await
4451 .unwrap();
4452 assert_eq!(v["duplicates_found"], 1);
4453 assert_eq!(v["removed"], 0);
4454
4455 let memories = store.scan(Galaxy::Codex, 100).unwrap();
4456 assert_eq!(memories.len(), 3);
4457 }
4458
4459 #[tokio::test]
4460 async fn memory_deduplicate_hash_execute() {
4461 let store = test_store();
4462 let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
4463 let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
4464 let _ = store.put(Galaxy::Codex, &m1);
4465 let _ = store.put(Galaxy::Codex, &m2);
4466 let _ = store.put(
4467 Galaxy::Codex,
4468 &Memory::new(Galaxy::Codex, "unique content".into()),
4469 );
4470
4471 let tool = MemoryDeduplicateTool::new(store.clone(), None);
4472 let mut ctx = Context::default();
4473 let v = tool
4474 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
4475 .await
4476 .unwrap();
4477 assert_eq!(v["duplicates_found"], 1);
4478 assert_eq!(v["removed"], 1);
4479
4480 let memories = store.scan(Galaxy::Codex, 100).unwrap();
4481 assert_eq!(memories.len(), 2);
4482 }
4483
4484 #[tokio::test]
4485 async fn memory_deduplicate_deindexes_removed_memories() {
4486 let (_dir, store, search) = hybrid_fixture();
4489
4490 let m1 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
4491 let m2 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
4492 let id1 = m1.metadata.id;
4493 let id2 = m2.metadata.id;
4494 let _ = store.put(Galaxy::Codex, &m1);
4495 let _ = store.put(Galaxy::Codex, &m2);
4496 for mem in [&m1, &m2] {
4497 let mut writer = search.writer().unwrap();
4498 search
4499 .add_document(
4500 &mut writer,
4501 &mem.metadata.id.to_string(),
4502 mem.metadata.galaxy.db_name(),
4503 &mem.content,
4504 &mem.metadata.tags,
4505 mem.metadata.created_at.timestamp(),
4506 )
4507 .unwrap();
4508 search.commit(&mut writer).unwrap();
4509 }
4510
4511 let before = search.search_ids("index drift", 100).unwrap();
4513 assert_eq!(before.len(), 2);
4514
4515 let tool = MemoryDeduplicateTool::new(store.clone(), Some(search.clone()));
4516 let mut ctx = Context::default();
4517 let v = tool
4518 .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
4519 .await
4520 .unwrap();
4521 assert_eq!(v["removed"], 1);
4522
4523 let after = search.search_ids("index drift", 100).unwrap();
4526 assert_eq!(after.len(), 1, "search index should only contain survivors");
4527 assert!(
4528 after.contains(&id1) || after.contains(&id2),
4529 "survivor should be one of the original memories"
4530 );
4531 }
4532
4533 #[tokio::test]
4534 async fn memory_deduplicate_content_mode() {
4535 let store = test_store();
4536 let m1 = Memory::new(Galaxy::Codex, "same text".into());
4537 let m2 = Memory::new(Galaxy::Codex, "same text".into());
4538 let _ = store.put(Galaxy::Codex, &m1);
4539 let _ = store.put(Galaxy::Codex, &m2);
4540
4541 let tool = MemoryDeduplicateTool::new(store, None);
4542 let mut ctx = Context::default();
4543 let v = tool
4544 .call(&mut ctx, json!({"mode": "content", "dry_run": true}))
4545 .await
4546 .unwrap();
4547 assert_eq!(v["duplicates_found"], 1);
4548 }
4549
4550 #[tokio::test]
4551 async fn memory_deduplicate_no_duplicates() {
4552 let store = test_store();
4553 let _ = store.put(
4554 Galaxy::Codex,
4555 &Memory::new(Galaxy::Codex, "content a".into()),
4556 );
4557 let _ = store.put(
4558 Galaxy::Codex,
4559 &Memory::new(Galaxy::Codex, "content b".into()),
4560 );
4561
4562 let tool = MemoryDeduplicateTool::new(store, None);
4563 let mut ctx = Context::default();
4564 let v = tool.call(&mut ctx, json!({})).await.unwrap();
4565 assert_eq!(v["duplicates_found"], 0);
4566 }
4567
4568 #[tokio::test]
4569 async fn memory_deduplicate_invalid_mode() {
4570 let store = test_store();
4571 let tool = MemoryDeduplicateTool::new(store, None);
4572 let mut ctx = Context::default();
4573 let result = tool.call(&mut ctx, json!({"mode": "invalid"})).await;
4574 assert!(result.is_err());
4575 }
4576
4577 #[tokio::test]
4578 async fn memory_export_json() {
4579 let store = test_store();
4580 populate_memories(&store, Galaxy::Codex);
4581 let tool = MemoryExportTool::new(store);
4582 let mut ctx = Context::default();
4583 let v = tool
4584 .call(&mut ctx, json!({"format": "json"}))
4585 .await
4586 .unwrap();
4587 assert_eq!(v["format"], "json");
4588 assert_eq!(v["count"], 3);
4589 assert!(v["export"].as_str().unwrap().contains("First memory"));
4590 }
4591
4592 #[tokio::test]
4593 async fn memory_export_csv() {
4594 let store = test_store();
4595 populate_memories(&store, Galaxy::Codex);
4596 let tool = MemoryExportTool::new(store);
4597 let mut ctx = Context::default();
4598 let v = tool.call(&mut ctx, json!({"format": "csv"})).await.unwrap();
4599 let csv = v["export"].as_str().unwrap();
4600 assert!(csv.contains("id,content,tags"));
4601 assert!(csv.contains("First memory"));
4602 }
4603
4604 #[tokio::test]
4605 async fn memory_export_markdown() {
4606 let store = test_store();
4607 populate_memories(&store, Galaxy::Codex);
4608 let tool = MemoryExportTool::new(store);
4609 let mut ctx = Context::default();
4610 let v = tool
4611 .call(&mut ctx, json!({"format": "markdown"}))
4612 .await
4613 .unwrap();
4614 let md = v["export"].as_str().unwrap();
4615 assert!(md.contains("# Memory Export"));
4616 assert!(md.contains("First memory"));
4617 }
4618
4619 #[tokio::test]
4620 async fn memory_export_invalid_format() {
4621 let store = test_store();
4622 let tool = MemoryExportTool::new(store);
4623 let mut ctx = Context::default();
4624 let result = tool.call(&mut ctx, json!({"format": "xml"})).await;
4625 assert!(result.is_err());
4626 }
4627
4628 #[tokio::test]
4629 async fn memory_export_empty_galaxy() {
4630 let store = test_store();
4631 let tool = MemoryExportTool::new(store);
4632 let mut ctx = Context::default();
4633 let v = tool
4634 .call(&mut ctx, json!({"format": "json"}))
4635 .await
4636 .unwrap();
4637 assert_eq!(v["count"], 0);
4638 }
4639
4640 #[tokio::test]
4641 async fn memory_sort_and_filter_are_winnowing_basket_gana() {
4642 let store = test_store();
4643 assert_eq!(
4644 MemorySortTool::new(store.clone()).gana(),
4645 Gana::WinnowingBasket
4646 );
4647 assert_eq!(
4648 MemoryFilterTool::new(store.clone()).gana(),
4649 Gana::WinnowingBasket
4650 );
4651 assert_eq!(
4652 MemoryDeduplicateTool::new(store.clone(), None).gana(),
4653 Gana::WinnowingBasket
4654 );
4655 assert_eq!(MemoryExportTool::new(store).gana(), Gana::WinnowingBasket);
4656 }
4657
4658 #[test]
4667 fn hybrid_recall_routes_expose_query_schema() {
4668 let dir = tempfile::tempdir().unwrap();
4669 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4670 for tool in [
4671 MemoryHybridRecallTool::new(store.clone(), None, None),
4672 MemoryHybridRecallTool::as_search(store, None, None),
4673 ] {
4674 let schema = tool.input_schema();
4675 assert_eq!(schema["type"], "object");
4676 assert!(schema["properties"].get("query").is_some());
4677 assert_eq!(schema["required"], json!(["query"]));
4678 }
4679 }
4680
4681 fn hybrid_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4684 let dir = tempfile::tempdir().unwrap();
4685 let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
4686 let tantivy_dir = dir.path().join("tantivy");
4687 std::fs::create_dir_all(&tantivy_dir).unwrap();
4688 let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
4689 (dir, store, search)
4690 }
4691
4692 fn index_memory(
4693 store: &Arc<MemoryStore>,
4694 search: &Arc<SearchEngine>,
4695 galaxy: Galaxy,
4696 content: &str,
4697 ) {
4698 let mem = Memory::new(galaxy, content.to_string());
4699 let id = mem.metadata.id;
4700 store.put(galaxy, &mem).unwrap();
4701 let mut writer = search.writer().unwrap();
4702 search
4703 .add_document(
4704 &mut writer,
4705 &id.to_string(),
4706 galaxy.db_name(),
4707 content,
4708 &mem.metadata.tags,
4709 mem.metadata.created_at.timestamp(),
4710 )
4711 .unwrap();
4712 search.commit(&mut writer).unwrap();
4713 }
4714
4715 #[tokio::test]
4716 async fn hybrid_recall_excludes_private_memories() {
4717 let (_dir, store, search) = hybrid_fixture();
4718
4719 let mut priv_mem = Memory::new(Galaxy::Codex, "private secret plan alpha".to_string());
4721 priv_mem.metadata.is_private = true;
4722 let id = priv_mem.metadata.id;
4723 store.put(Galaxy::Codex, &priv_mem).unwrap();
4724 {
4725 let mut writer = search.writer().unwrap();
4726 search
4727 .add_document(
4728 &mut writer,
4729 &id.to_string(),
4730 "codex",
4731 "private secret plan alpha",
4732 &[],
4733 priv_mem.metadata.created_at.timestamp(),
4734 )
4735 .unwrap();
4736 search.commit(&mut writer).unwrap();
4737 }
4738
4739 index_memory(
4741 &store,
4742 &search,
4743 Galaxy::Codex,
4744 "public plan alpha documentation",
4745 );
4746
4747 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4748 let v = tool
4749 .call(
4750 &mut Context::default(),
4751 json!({"query": "plan alpha", "galaxy": "codex"}),
4752 )
4753 .await
4754 .unwrap();
4755 let results = v["results"].as_array().unwrap();
4756 let contents: Vec<&str> = results
4757 .iter()
4758 .filter_map(|r| r["content"].as_str())
4759 .collect();
4760 assert!(
4761 !contents.iter().any(|c| c.contains("private")),
4762 "private memory leaked through hybrid recall: {results:?}"
4763 );
4764 assert!(
4765 contents.iter().any(|c| c.contains("public")),
4766 "public memory missing from hybrid recall: {results:?}"
4767 );
4768 }
4769
4770 #[tokio::test]
4771 async fn batch_read_treats_private_as_miss() {
4772 let store = test_store();
4773 let mut priv_mem = Memory::new(Galaxy::Codex, "private batch note".into());
4774 priv_mem.metadata.is_private = true;
4775 let priv_id = priv_mem.metadata.id;
4776 store.put(Galaxy::Codex, &priv_mem).unwrap();
4777 let pub_mem = Memory::new(Galaxy::Codex, "public batch note".into());
4778 let pub_id = pub_mem.metadata.id;
4779 store.put(Galaxy::Codex, &pub_mem).unwrap();
4780
4781 let tool = MemoryBatchReadTool::new(store);
4782 let v = tool
4783 .call(
4784 &mut Context::default(),
4785 json!({"galaxy": "codex", "ids": [priv_id.to_string(), pub_id.to_string()]}),
4786 )
4787 .await
4788 .unwrap();
4789 assert_eq!(v["found"], 1);
4790 assert_eq!(v["misses"], 1);
4791 assert!(
4792 !v["memories"]
4793 .as_array()
4794 .unwrap()
4795 .iter()
4796 .any(|m| m["content"].as_str().unwrap_or("").contains("private")),
4797 "private memory leaked through batch_read: {v}"
4798 );
4799 }
4800
4801 #[tokio::test]
4802 async fn hybrid_recall_incident_query_returns_only_relevant() {
4803 let (_dir, store, search) = hybrid_fixture();
4804 index_memory(
4805 &store,
4806 &search,
4807 Galaxy::Codex,
4808 "smoke test from wmClient: verify recall",
4809 );
4810 index_memory(
4811 &store,
4812 &search,
4813 Galaxy::Codex,
4814 "NES Evolution and Impact: a history of the console wars",
4815 );
4816 index_memory(
4817 &store,
4818 &search,
4819 Galaxy::Codex,
4820 "Insights on The Gateless Gate: koans and zen practice",
4821 );
4822 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4823 let mut ctx = Context::default();
4824 let v = tool
4825 .call(
4826 &mut ctx,
4827 json!({"query": "smoke test", "galaxy": "codex", "limit": 5}),
4828 )
4829 .await
4830 .unwrap();
4831 let results = v["results"].as_array().unwrap();
4832 assert_eq!(
4833 results.len(),
4834 1,
4835 "incident query must not return unrelated memories: {results:?}"
4836 );
4837 let hit = &results[0];
4838 assert_eq!(hit["source"], "fts");
4839 assert!(
4840 hit["content"]
4841 .as_str()
4842 .unwrap()
4843 .contains("smoke test from wmClient")
4844 );
4845 assert!(hit["normalized_score"].as_f64().unwrap() > 0.0);
4846 assert_eq!(v["count"], 1);
4847 }
4848
4849 #[tokio::test]
4850 async fn hybrid_recall_filters_stale_index_entries() {
4851 let (_dir, store, search) = hybrid_fixture();
4854 index_memory(
4855 &store,
4856 &search,
4857 Galaxy::Codex,
4858 "rust memory about ownership",
4859 );
4860 {
4861 let mut writer = search.writer().unwrap();
4862 search
4863 .add_document(
4864 &mut writer,
4865 "99999999-9999-9999-9999-999999999999",
4866 "codex",
4867 "rust ghost memory",
4868 &[],
4869 1700000000,
4870 )
4871 .unwrap();
4872 search.commit(&mut writer).unwrap();
4873 }
4874
4875 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4876 let mut ctx = Context::default();
4877 let v = tool
4878 .call(&mut ctx, json!({"query": "rust", "limit": 10}))
4879 .await
4880 .unwrap();
4881 let results = v["results"].as_array().unwrap();
4882 assert_eq!(results.len(), 1);
4883 assert_ne!(
4884 results[0]["id"].as_str().unwrap(),
4885 "99999999-9999-9999-9999-999999999999"
4886 );
4887 }
4888
4889 #[tokio::test]
4890 async fn hybrid_recall_respects_min_score_arg() {
4891 let (_dir, store, search) = hybrid_fixture();
4892 index_memory(&store, &search, Galaxy::Codex, "alpha");
4893 let filler = format!("alpha {}", "zzz ".repeat(400));
4894 index_memory(&store, &search, Galaxy::Codex, &filler);
4895
4896 let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
4898 let mut ctx = Context::default();
4899 let v = tool
4900 .call(&mut ctx, json!({"query": "alpha", "limit": 10}))
4901 .await
4902 .unwrap();
4903 assert_eq!(v["count"], 2);
4904
4905 let scores: Vec<f64> = v["results"]
4907 .as_array()
4908 .unwrap()
4909 .iter()
4910 .map(|r| r["score"].as_f64().unwrap())
4911 .collect();
4912 let lo = scores.iter().copied().fold(f64::MAX, f64::min);
4913 let hi = scores.iter().copied().fold(0.0, f64::max);
4914 let mid = f64::midpoint(hi, lo);
4915
4916 let v = tool
4917 .call(
4918 &mut ctx,
4919 json!({"query": "alpha", "limit": 10, "min_score": mid}),
4920 )
4921 .await
4922 .unwrap();
4923 assert_eq!(v["count"], 1);
4924 assert!((v["results"][0]["score"].as_f64().unwrap() - hi).abs() < 1e-3);
4925 }
4926
4927 #[tokio::test]
4928 async fn hybrid_recall_or_coverage_finds_partial_matches() {
4929 let (_dir, store, search) = hybrid_fixture();
4933 index_memory(&store, &search, Galaxy::Codex, "alpha only here");
4934 index_memory(&store, &search, Galaxy::Codex, "alpha beta gamma delta");
4935
4936 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4937 let mut ctx = Context::default();
4938 let v = tool
4939 .call(&mut ctx, json!({"query": "alpha beta gamma", "limit": 10}))
4940 .await
4941 .unwrap();
4942 let results = v["results"].as_array().unwrap();
4943 assert_eq!(results.len(), 1);
4946 assert!(
4947 results[0]["content"]
4948 .as_str()
4949 .unwrap()
4950 .contains("alpha beta gamma")
4951 );
4952
4953 let v = tool
4956 .call(
4957 &mut ctx,
4958 json!({"query": "alpha beta gamma zeta", "limit": 10}),
4959 )
4960 .await
4961 .unwrap();
4962 let results = v["results"].as_array().unwrap();
4963 assert_eq!(
4964 results.len(),
4965 1,
4966 "OR + coverage must require 2/4 token coverage: {results:?}"
4967 );
4968 assert!(
4969 results[0]["content"]
4970 .as_str()
4971 .unwrap()
4972 .contains("alpha beta gamma")
4973 );
4974 for r in results {
4975 assert!(
4976 matches!(r["source"].as_str(), Some("fts")),
4977 "results should be tagged fts"
4978 );
4979 }
4980 }
4981
4982 fn index_tagged_memory(
4983 store: &Arc<MemoryStore>,
4984 search: &Arc<SearchEngine>,
4985 galaxy: Galaxy,
4986 content: &str,
4987 tags: &[&str],
4988 ) {
4989 let mut mem = Memory::new(galaxy, content.to_string());
4990 mem.metadata.tags = tags.iter().map(ToString::to_string).collect();
4991 let id = mem.metadata.id;
4992 store.put(galaxy, &mem).unwrap();
4993 let mut writer = search.writer().unwrap();
4994 search
4995 .add_document(
4996 &mut writer,
4997 &id.to_string(),
4998 galaxy.db_name(),
4999 content,
5000 &mem.metadata.tags,
5001 mem.metadata.created_at.timestamp(),
5002 )
5003 .unwrap();
5004 search.commit(&mut writer).unwrap();
5005 }
5006
5007 fn aggregate_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
5008 let (dir, store, search) = hybrid_fixture();
5009 index_tagged_memory(
5011 &store,
5012 &search,
5013 Galaxy::Codex,
5014 "I started learning Rust.",
5015 &["user", "session_002"],
5016 );
5017 index_tagged_memory(
5018 &store,
5019 &search,
5020 Galaxy::Codex,
5021 "I finished my first Rust project, a CLI tool.",
5022 &["user", "session_007"],
5023 );
5024 index_tagged_memory(
5025 &store,
5026 &search,
5027 Galaxy::Codex,
5028 "I got a job as a systems engineer using Rust.",
5029 &["user", "session_012"],
5030 );
5031 index_tagged_memory(
5034 &store,
5035 &search,
5036 Galaxy::Codex,
5037 "I started learning Go.",
5038 &["user", "session_003"],
5039 );
5040 index_tagged_memory(
5041 &store,
5042 &search,
5043 Galaxy::Codex,
5044 "I got a job as a backend engineer using Go.",
5045 &["user", "session_015"],
5046 );
5047 (dir, store, search)
5048 }
5049
5050 #[tokio::test]
5051 async fn aggregate_session_span_isolated_by_rarest_term() {
5052 let (_dir, store, search) = aggregate_fixture();
5053 let tool = MemoryAggregateTool::new(Some(search), store);
5054 let mut ctx = Context::default();
5055 let v = tool
5056 .call(
5057 &mut ctx,
5058 json!({
5059 "query": "How long did it take from starting Rust to getting a job using it?",
5060 "metric": "session_span",
5061 }),
5062 )
5063 .await
5064 .unwrap();
5065 assert_eq!(v["aggregate"]["value"], 10, "session_012 - session_002");
5066 assert_eq!(v["aggregate"]["unit"], "sessions");
5067 assert_eq!(v["aggregate"]["content"], "10 sessions");
5068 }
5069
5070 #[tokio::test]
5071 async fn aggregate_session_count() {
5072 let (_dir, store, search) = aggregate_fixture();
5073 let tool = MemoryAggregateTool::new(Some(search), store);
5074 let mut ctx = Context::default();
5075 let v = tool
5076 .call(
5077 &mut ctx,
5078 json!({
5079 "query": "How long did it take from starting Rust to getting a job using it?",
5080 "metric": "session_count",
5081 }),
5082 )
5083 .await
5084 .unwrap();
5085 assert_eq!(v["aggregate"]["value"], 2);
5091 }
5092
5093 #[tokio::test]
5094 async fn aggregate_count_needs_no_session_tags() {
5095 let (_dir, store, search) = aggregate_fixture();
5096 let tool = MemoryAggregateTool::new(Some(search), store);
5097 let mut ctx = Context::default();
5098 let v = tool
5099 .call(
5100 &mut ctx,
5101 json!({"query": "Rust project", "metric": "count"}),
5102 )
5103 .await
5104 .unwrap();
5105 assert_eq!(v["aggregate"]["value"], 3);
5107 }
5108
5109 #[tokio::test]
5113 async fn aggregate_single_session_falls_back_honestly() {
5114 let (_dir, store, search) = aggregate_fixture();
5115 let tool = MemoryAggregateTool::new(Some(search), store);
5116 let mut ctx = Context::default();
5117 let v = tool
5119 .call(
5120 &mut ctx,
5121 json!({"query": "CLI tool", "metric": "session_count"}),
5122 )
5123 .await
5124 .unwrap();
5125 assert_eq!(v["aggregate"]["value"], 1, "one session in evidence: {v}");
5126 assert_eq!(v["anchor"], "session_tagged_fallback");
5127 let v = tool
5128 .call(
5129 &mut ctx,
5130 json!({"query": "CLI tool", "metric": "session_span"}),
5131 )
5132 .await
5133 .unwrap();
5134 assert_eq!(v["aggregate"]["value"], 0, "single point spans 0: {v}");
5135 }
5136
5137 #[tokio::test]
5138 async fn aggregate_rejects_unknown_metric() {
5139 let (_dir, store, search) = aggregate_fixture();
5140 let tool = MemoryAggregateTool::new(Some(search), store);
5141 let mut ctx = Context::default();
5142 let err = tool
5143 .call(&mut ctx, json!({"query": "x", "metric": "median"}))
5144 .await
5145 .unwrap_err();
5146 assert!(err.to_string().contains("unknown metric"));
5147 }
5148
5149 #[tokio::test]
5153 async fn hybrid_recall_rejects_out_of_range_floors() {
5154 let (_dir, store, search) = hybrid_fixture();
5155 let tool = MemoryHybridRecallTool::new(store, Some(search), None);
5156 let mut ctx = Context::default();
5157
5158 for (key, value) in [
5159 ("min_trust", json!(2.0)),
5160 ("min_trust", json!(-0.1)),
5161 ("min_importance", json!(1.5)),
5162 ("min_importance", json!(-0.5)),
5163 ("min_score_ratio", json!(1.5)),
5164 ("min_score_ratio", json!(-1.0)),
5165 ("min_score", json!(-3.0)),
5166 ("min_trust", json!("2.0")),
5167 ] {
5168 let err = tool
5169 .call(&mut ctx, json!({"query": "x", key: value}))
5170 .await
5171 .unwrap_err();
5172 assert!(
5173 err.to_string().contains(key),
5174 "{key}={value} must be rejected by name, got: {err}"
5175 );
5176 }
5177
5178 let v = tool
5180 .call(
5181 &mut ctx,
5182 json!({
5183 "query": "x",
5184 "min_trust": 1.0,
5185 "min_importance": 0.0,
5186 "min_score_ratio": 0.0,
5187 "min_score": 0.0,
5188 }),
5189 )
5190 .await
5191 .unwrap();
5192 assert_eq!(v["min_trust"], 1.0, "valid boundary floor disclosed: {v}");
5193 }
5194
5195 #[tokio::test]
5198 async fn memory_update_rejects_out_of_range_importance() {
5199 let store = test_store();
5200 let mut mem = Memory::new(Galaxy::Codex, "audit validation target".into());
5201 mem.metadata.importance = 0.5;
5202 store.put(Galaxy::Codex, &mem).unwrap();
5203 let id = mem.metadata.id;
5204
5205 let tool = MemoryUpdateTool::new(store.clone(), None);
5206 let mut ctx = Context::default();
5207 for bad in [json!(2.0), json!(999), json!(-0.25), json!("1.5")] {
5208 let err = tool
5209 .call(&mut ctx, json!({"id": id, "importance": bad}))
5210 .await
5211 .unwrap_err();
5212 assert!(
5213 err.to_string().contains("importance"),
5214 "importance={bad} must be rejected, got: {err}"
5215 );
5216 }
5217 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
5218 assert!(
5219 (stored.metadata.importance - 0.5).abs() < f32::EPSILON,
5220 "rejected updates must not mutate the record: {}",
5221 stored.metadata.importance
5222 );
5223
5224 let v = tool
5226 .call(&mut ctx, json!({"id": id, "importance": 0.75}))
5227 .await
5228 .unwrap();
5229 assert_eq!(v["status"], "success", "{v}");
5230 let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
5231 assert!((stored.metadata.importance - 0.75).abs() < f32::EPSILON);
5232 }
5233
5234 #[test]
5237 fn weak_evidence_abstention_floor_and_coverage() {
5238 let fts = vec![json!({"source": "fts", "raw_score": 0.5})];
5240 assert!(weak_evidence_abstention_with(&fts, "alpha", 0.0, 0.0).is_none());
5241
5242 let weak =
5244 weak_evidence_abstention_with(&fts, "alpha", 2.0, 0.0).expect("below floor abstains");
5245 assert_eq!(weak["reason"], "top_below_floor");
5246 assert_eq!(weak["signal"], "bm25");
5247 assert_eq!(weak["scope"], "retrieval");
5248 assert_eq!(weak["status"], "insufficient_evidence");
5249
5250 let strong = vec![json!({"source": "hybrid", "raw_score": 4.0})];
5252 assert!(weak_evidence_abstention_with(&strong, "alpha", 2.0, 0.0).is_none());
5253
5254 let vector_only = vec![json!({"source": "hybrid", "raw_score": 0.0, "vector_score": 0.9})];
5256 assert!(weak_evidence_abstention_with(&vector_only, "alpha", 2.0, 0.0).is_none());
5257
5258 let episodic = vec![json!({"source": "episodic", "matched_terms": 1})];
5260 let low = weak_evidence_abstention_with(&episodic, "alpha beta gamma delta", 0.0, 0.5)
5261 .expect("low coverage abstains");
5262 assert_eq!(low["reason"], "coverage_below_floor");
5263 assert_eq!(low["signal"], "coverage");
5264 assert_eq!(low["matched_terms"], 1);
5265
5266 let full = vec![json!({"source": "episodic", "matched_terms": 4})];
5268 assert!(weak_evidence_abstention_with(&full, "alpha beta gamma delta", 0.0, 0.5).is_none());
5269
5270 let association = vec![json!({"source": "association", "score": 0.1})];
5272 assert!(weak_evidence_abstention_with(&association, "alpha", 2.0, 0.5).is_none());
5273 }
5274
5275 #[test]
5276 fn weak_evidence_abstention_fts_uses_token_coverage() {
5277 let partial =
5280 vec![json!({"source": "fts", "raw_score": 4.5, "content": "alpha beta only here"})];
5281 let hit = weak_evidence_abstention_with(&partial, "alpha beta gamma delta", 0.0, 0.6)
5282 .expect("partial coverage abstains");
5283 assert_eq!(hit["reason"], "coverage_below_floor");
5284 assert_eq!(hit["signal"], "coverage");
5285 assert!((hit["coverage"].as_f64().unwrap() - 0.5).abs() < 1e-6);
5286
5287 let full = vec![
5289 json!({"source": "fts", "raw_score": 4.5, "content": "alpha beta gamma delta here"}),
5290 ];
5291 assert!(weak_evidence_abstention_with(&full, "alpha beta gamma delta", 0.0, 0.6).is_none());
5292 }
5293}