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