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