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