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