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