Skip to main content

wm_tools/expansion/
memory_ops.rs

1//! Memory operation tools — consolidate, decay, batch_read, update, tag, stats, hybrid_recall.
2
3#![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    galaxy_name, int_prop, num_prop, parse_galaxy, parse_galaxy_or, schema, str_prop,
18};
19
20/// Resolve a memory id across all memory galaxies. Associations may point at
21/// records in any galaxy; callers should not have to guess which one.
22fn resolve_memory_across_galaxies(
23    store: &MemoryStore,
24    id: uuid::Uuid,
25) -> Option<(wm_core::Galaxy, wm_memory::Memory)> {
26    for galaxy in wm_core::Galaxy::memory_galaxies() {
27        if let Ok(Some(mem)) = store.get(galaxy, id) {
28            return Some((galaxy, mem));
29        }
30    }
31    None
32}
33
34/// `memory.consolidate` — deduplicate memories by content_hash within a galaxy.
35pub struct MemoryConsolidateTool {
36    store: Arc<MemoryStore>,
37    search: Option<Arc<SearchEngine>>,
38    stats: ToolStats,
39    effects: EffectRow,
40}
41
42impl MemoryConsolidateTool {
43    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
44        Self {
45            store,
46            search,
47            stats: ToolStats::default(),
48            effects: EffectRow {
49                writes: super::common::memory_galaxy_writes(),
50                reads: super::common::memory_galaxy_reads(),
51                destructive: true,
52                ..Default::default()
53            },
54        }
55    }
56}
57
58#[async_trait]
59impl Tool for MemoryConsolidateTool {
60    fn name(&self) -> &str {
61        "memory.consolidate"
62    }
63    fn gana(&self) -> Gana {
64        Gana::Encampment
65    }
66    fn effects(&self) -> &EffectRow {
67        &self.effects
68    }
69    fn description(&self) -> &str {
70        "Deduplicate memories by content_hash within a galaxy"
71    }
72    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
73        let galaxy = args
74            .get("galaxy")
75            .and_then(|v| v.as_str())
76            .unwrap_or("codex");
77        let galaxy = parse_galaxy(galaxy)?;
78        // Full scan: research/sessions galaxies exceed the legacy 10k scan cap,
79        // which silently left the tail un-consolidated (B5 heritage dedupe).
80        let memories = self.store.scan_all(galaxy)?;
81        let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
82        let mut duplicates = 0u32;
83        for mem in &memories {
84            let hash = &mem.metadata.content_hash;
85            if let Some(existing_id) = seen_hashes.get(hash) {
86                if *existing_id != mem.metadata.id {
87                    self.store.delete(galaxy, mem.metadata.id)?;
88                    super::common::deindex(self.search.as_deref(), &mem.metadata.id.to_string());
89                    duplicates += 1;
90                }
91            } else {
92                seen_hashes.insert(hash.clone(), mem.metadata.id);
93            }
94        }
95        Ok(json!({
96            "status": "success",
97            "galaxy": galaxy_name(galaxy),
98            "scanned": memories.len(),
99            "duplicates_removed": duplicates,
100        }))
101    }
102    fn stats(&self) -> &ToolStats {
103        &self.stats
104    }
105}
106
107/// `memory.decay` — lower importance of old, low-access memories.
108pub struct MemoryDecayTool {
109    store: Arc<MemoryStore>,
110    stats: ToolStats,
111    effects: EffectRow,
112}
113
114impl MemoryDecayTool {
115    pub fn new(store: Arc<MemoryStore>) -> Self {
116        Self {
117            store,
118            stats: ToolStats::default(),
119            effects: EffectRow {
120                writes: super::common::memory_galaxy_writes(),
121                reads: super::common::memory_galaxy_reads(),
122                ..Default::default()
123            },
124        }
125    }
126}
127
128#[async_trait]
129impl Tool for MemoryDecayTool {
130    fn name(&self) -> &str {
131        "memory.decay"
132    }
133    fn gana(&self) -> Gana {
134        Gana::WinnowingBasket
135    }
136    fn effects(&self) -> &EffectRow {
137        &self.effects
138    }
139    fn description(&self) -> &str {
140        "Lower importance of old, low-access memories (never deletes)"
141    }
142    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
143        let galaxy = args
144            .get("galaxy")
145            .and_then(|v| v.as_str())
146            .unwrap_or("codex");
147        let galaxy = parse_galaxy(galaxy)?;
148        let threshold = args
149            .get("importance_threshold")
150            .and_then(serde_json::Value::as_f64)
151            .unwrap_or(0.3) as f32;
152        let decay_factor = args
153            .get("decay_factor")
154            .and_then(serde_json::Value::as_f64)
155            .unwrap_or(0.9) as f32;
156        let memories = self.store.scan(galaxy, 10_000)?;
157        let mut decayed = 0u32;
158        for mem in &memories {
159            if mem.metadata.importance < threshold {
160                let mut updated = mem.clone();
161                updated.metadata.importance =
162                    (updated.metadata.importance * decay_factor).clamp(0.0, 1.0);
163                if (updated.metadata.importance - mem.metadata.importance).abs() > 0.001 {
164                    self.store.put(galaxy, &updated)?;
165                    decayed += 1;
166                }
167            }
168        }
169        Ok(json!({
170            "status": "success",
171            "galaxy": galaxy_name(galaxy),
172            "scanned": memories.len(),
173            "decayed": decayed,
174        }))
175    }
176    fn stats(&self) -> &ToolStats {
177        &self.stats
178    }
179}
180
181/// `memory.batch_read` — read multiple memories by ID.
182pub struct MemoryBatchReadTool {
183    store: Arc<MemoryStore>,
184    stats: ToolStats,
185    effects: EffectRow,
186}
187
188impl MemoryBatchReadTool {
189    pub fn new(store: Arc<MemoryStore>) -> Self {
190        Self {
191            store,
192            stats: ToolStats::default(),
193            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
194        }
195    }
196}
197
198#[async_trait]
199impl Tool for MemoryBatchReadTool {
200    fn name(&self) -> &str {
201        "memory.batch_read"
202    }
203    fn gana(&self) -> Gana {
204        Gana::WinnowingBasket
205    }
206    fn effects(&self) -> &EffectRow {
207        &self.effects
208    }
209    fn description(&self) -> &str {
210        "Read multiple memories by ID from a galaxy"
211    }
212    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
213        let galaxy = args
214            .get("galaxy")
215            .and_then(|v| v.as_str())
216            .unwrap_or("codex");
217        let galaxy = parse_galaxy(galaxy)?;
218        let ids = args
219            .get("ids")
220            .and_then(|v| v.as_array())
221            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'ids' array".into()))?;
222        let mut results = Vec::new();
223        let mut misses = 0u32;
224        for id_val in ids {
225            if let Some(id_str) = id_val.as_str() {
226                if let Ok(id) = uuid::Uuid::parse_str(id_str) {
227                    match self.store.get(galaxy, id)? {
228                        Some(mem)
229                            if crate::expansion::common::mcp_visible(&mem)
230                                && crate::expansion::common::validity_visible(&mem) =>
231                        {
232                            results.push(json!({
233                                "id": mem.metadata.id,
234                                "content": mem.content,
235                                "tags": mem.metadata.tags,
236                                "importance": mem.metadata.importance,
237                            }));
238                        }
239                        // Private memories are treated like misses — they never
240                        // appear in MCP responses.
241                        Some(_) => {
242                            misses += 1;
243                        }
244                        None => {
245                            misses += 1;
246                        }
247                    }
248                }
249            }
250        }
251        Ok(json!({
252            "status": "success",
253            "galaxy": galaxy_name(galaxy),
254            "found": results.len(),
255            "misses": misses,
256            "memories": results,
257        }))
258    }
259    fn stats(&self) -> &ToolStats {
260        &self.stats
261    }
262}
263
264/// `memory.update` — update tags or importance of a memory.
265///
266/// If a `SearchEngine` is provided, the updated memory is re-indexed into
267/// Tantivy (delete old doc, add new doc, commit).
268pub struct MemoryUpdateTool {
269    store: Arc<MemoryStore>,
270    search: Option<Arc<SearchEngine>>,
271    stats: ToolStats,
272    effects: EffectRow,
273}
274
275impl MemoryUpdateTool {
276    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
277        Self {
278            store,
279            search,
280            stats: ToolStats::default(),
281            effects: EffectRow {
282                writes: super::common::memory_galaxy_writes(),
283                reads: super::common::memory_galaxy_reads(),
284                // Landlock v1 first batch (P-SANDBOX-3): store-root-only body.
285                sandbox: wm_core::Sandbox::StoreScoped,
286                ..Default::default()
287            },
288        }
289    }
290}
291
292#[async_trait]
293impl Tool for MemoryUpdateTool {
294    fn name(&self) -> &str {
295        "memory.update"
296    }
297    fn gana(&self) -> Gana {
298        Gana::Encampment
299    }
300    fn effects(&self) -> &EffectRow {
301        &self.effects
302    }
303    fn description(&self) -> &str {
304        "Update tags, importance, title/topic, or content of an existing memory"
305    }
306    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
307        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
308        let id_str = args
309            .get("id")
310            .and_then(|v| v.as_str())
311            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
312        let id = uuid::Uuid::parse_str(id_str)
313            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
314        if let Some(search) = &self.search {
315            if search.is_readonly() {
316                return Err(wm_core::CoreError::InvalidArgs(
317                    "read-only mode: memory.update disabled (another process owns the index)"
318                        .into(),
319                ));
320            }
321        }
322        let mut mem = self.store.get(galaxy, id)?.ok_or_else(|| {
323            wm_core::CoreError::NotFound(format!(
324                "Memory {id} not found in {}",
325                galaxy_name(galaxy)
326            ))
327        })?;
328        let previous_hash = mem.metadata.content_hash.clone();
329        let content_changed = args.get("content").and_then(|v| v.as_str()).is_some();
330        if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
331            mem.metadata.tags = tags
332                .iter()
333                .filter_map(|t| t.as_str().map(String::from))
334                .collect();
335        }
336        // Importance is applied verbatim: class ceilings/floors live in
337        // the pipeline write gate (V8 S5/S11d), the single seam every
338        // dispatch passes through. Direct tool calls bypass the gate by
339        // construction — same contract as the create path.
340        if let Some(importance) = args.get("importance").and_then(serde_json::Value::as_f64) {
341            mem.metadata.importance = importance as f32;
342        }
343        // Envelope v2 (S4): title/topic are settable and clearable
344        // (explicit null clears; absent leaves untouched).
345        if let Some(title) = args.get("title") {
346            mem.metadata.title = title
347                .as_str()
348                .map(str::trim)
349                .filter(|s| !s.is_empty())
350                .map(String::from);
351        }
352        if let Some(topic) = args.get("topic") {
353            mem.metadata.topic = topic
354                .as_str()
355                .map(str::trim)
356                .filter(|s| !s.is_empty())
357                .map(String::from);
358        }
359        if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
360            mem.content = content.to_string();
361            // Content changes invalidate the hash — keep it in sync so
362            // dedup and content-hash lookups stay truthful.
363            mem.metadata.content_hash = wm_memory::content_hash(content);
364            // V8 S11c: content changes bump the revision counter; the
365            // chain entry itself is recorded after the row lands.
366            mem.metadata.revision_count = mem.metadata.revision_count.saturating_add(1);
367        }
368        self.store.put(galaxy, &mem)?;
369
370        // V8 S11c: append the revision entry — seq, hashes, and the
371        // attributed actor from the dispatch context. A chain failure
372        // degrades loud (warn + disclosure), never silently.
373        let mut revision_disclosure = None;
374        if content_changed {
375            let actor = wm_memory::RevisionActor {
376                session: ctx.session_id.map(|sid| sid.to_string()),
377                user: ctx.user_id.clone(),
378                compartment: ctx.compartment.clone(),
379            };
380            match self.store.record_revision(
381                galaxy,
382                id,
383                &previous_hash,
384                &mem.metadata.content_hash,
385                actor,
386            ) {
387                Ok(rev) => {
388                    revision_disclosure = Some(serde_json::json!({
389                        "seq": rev.seq,
390                        "old_hash": rev.old_hash,
391                        "new_hash": rev.new_hash,
392                    }));
393                }
394                Err(e) => {
395                    tracing::warn!(error = %e, "revision chain record failed for {id_str}");
396                    revision_disclosure = Some(serde_json::json!({"record_failed": e.to_string()}));
397                }
398            }
399        }
400
401        // Phase 3 secrets hygiene: an update that introduces credential-
402        // shaped content gets the same boundary warning as memory.create.
403        let cred_kinds = args
404            .get("content")
405            .and_then(serde_json::Value::as_str)
406            .map(wm_memory::credential_shaped_content)
407            .unwrap_or_default();
408
409        // Re-index in Tantivy if search engine is available (non-fatal)
410        if let Some(search) = &self.search {
411            if let Err(e) = (|| {
412                let mut writer = search.writer()?;
413                search.delete_document(&mut writer, id_str)?;
414                search.add_document(
415                    &mut writer,
416                    id_str,
417                    galaxy_name(galaxy),
418                    &mem.content,
419                    &mem.metadata.tags,
420                    mem.metadata.created_at.timestamp(),
421                )?;
422                search.commit(&mut writer)?;
423                Ok::<(), wm_core::CoreError>(())
424            })() {
425                tracing::warn!("Tantivy re-indexing failed for memory {id_str}: {e}");
426            }
427        }
428
429        let mut response = json!({
430            "status": "success",
431            "id": mem.metadata.id,
432            "galaxy": galaxy_name(galaxy),
433            "tags": mem.metadata.tags,
434            "importance": mem.metadata.importance,
435            // V8 S11a: the write-audit journal scrapes `content_hash` from
436            // tool output (pipeline record_write_audit), so disclosing it
437            // here gives every update a hash-timeline journal entry;
438            // `prev_content_hash` is the agent/human-facing amendment trail.
439            "content_hash": mem.metadata.content_hash,
440        });
441        if content_changed {
442            response["prev_content_hash"] = json!(previous_hash);
443        }
444        if let Some(rev) = revision_disclosure {
445            response["revision"] = rev;
446        }
447        if !cred_kinds.is_empty() {
448            response["warnings"] = json!(
449                cred_kinds
450                    .iter()
451                    .map(|k| format!(
452                        "content looks like a credential ({k}) — {}",
453                        wm_memory::CREDENTIAL_ADVICE
454                    ))
455                    .collect::<Vec<String>>()
456            );
457        }
458        Ok(response)
459    }
460    fn stats(&self) -> &ToolStats {
461        &self.stats
462    }
463}
464
465/// `memory.revisions` — list or verify a memory's content revision chain
466/// (V8 S11c).
467///
468/// `action: "list"` returns the entries; `action: "verify"` grades the
469/// chain against the memory's current content hash (seq continuity, hash
470/// linkage, head match) — the tamper-evidence walk.
471pub struct MemoryRevisionsTool {
472    store: Arc<MemoryStore>,
473    stats: ToolStats,
474    effects: EffectRow,
475}
476
477impl MemoryRevisionsTool {
478    pub fn new(store: Arc<MemoryStore>) -> Self {
479        Self {
480            store,
481            stats: ToolStats::default(),
482            effects: EffectRow {
483                reads: super::common::memory_galaxy_reads(),
484                ..Default::default()
485            },
486        }
487    }
488}
489
490#[async_trait]
491impl Tool for MemoryRevisionsTool {
492    fn name(&self) -> &str {
493        "memory.revisions"
494    }
495    fn gana(&self) -> Gana {
496        Gana::WinnowingBasket
497    }
498    fn effects(&self) -> &EffectRow {
499        &self.effects
500    }
501    fn description(&self) -> &str {
502        "List or verify a memory's content revision chain (tamper evidence)"
503    }
504    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
505        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
506        let id_str = args
507            .get("id")
508            .and_then(|v| v.as_str())
509            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
510        let id = uuid::Uuid::parse_str(id_str)
511            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
512        let action = args
513            .get("action")
514            .and_then(|v| v.as_str())
515            .unwrap_or("list");
516        let revisions = self.store.revisions(galaxy, id)?;
517        match action {
518            "verify" => {
519                let mem = self.store.get(galaxy, id)?.ok_or_else(|| {
520                    wm_core::CoreError::NotFound(format!(
521                        "Memory {id} not found in {}",
522                        galaxy_name(galaxy)
523                    ))
524                })?;
525                let report =
526                    self.store
527                        .verify_revision_chain(galaxy, id, &mem.metadata.content_hash)?;
528                Ok(json!({
529                    "status": "success",
530                    "id": id,
531                    "galaxy": galaxy_name(galaxy),
532                    "action": "verify",
533                    "valid": report.valid,
534                    "entries": report.entries,
535                    "matches_head": report.matches_head,
536                    "breaks": report.breaks,
537                    "note": if report.entries == 0 {
538                        "no revisions recorded (never content-updated or pre-S11c)"
539                    } else { "" },
540                }))
541            }
542            "list" => Ok(json!({
543                "status": "success",
544                "id": id,
545                "galaxy": galaxy_name(galaxy),
546                "action": "list",
547                "count": revisions.len(),
548                "revisions": revisions,
549            })),
550            other => Err(wm_core::CoreError::InvalidArgs(format!(
551                "Unknown action '{other}' (expected 'list' or 'verify')"
552            ))),
553        }
554    }
555    fn stats(&self) -> &ToolStats {
556        &self.stats
557    }
558}
559
560/// `memory.tag` — add or remove tags from a memory.
561pub struct MemoryTagTool {
562    store: Arc<MemoryStore>,
563    stats: ToolStats,
564    effects: EffectRow,
565}
566
567impl MemoryTagTool {
568    pub fn new(store: Arc<MemoryStore>) -> Self {
569        Self {
570            store,
571            stats: ToolStats::default(),
572            effects: EffectRow {
573                writes: super::common::memory_galaxy_writes(),
574                reads: super::common::memory_galaxy_reads(),
575                ..Default::default()
576            },
577        }
578    }
579}
580
581#[async_trait]
582impl Tool for MemoryTagTool {
583    fn name(&self) -> &str {
584        "memory.tag"
585    }
586    fn gana(&self) -> Gana {
587        Gana::Net
588    }
589    fn effects(&self) -> &EffectRow {
590        &self.effects
591    }
592    fn description(&self) -> &str {
593        "Add or remove tags from a memory"
594    }
595    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
596        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
597        let id_str = args
598            .get("id")
599            .and_then(|v| v.as_str())
600            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'id'".into()))?;
601        let id = uuid::Uuid::parse_str(id_str)
602            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("Invalid UUID: {e}")))?;
603        let mut mem = self
604            .store
605            .get(galaxy, id)?
606            .ok_or_else(|| wm_core::CoreError::NotFound(format!("Memory {id} not found")))?;
607        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("add");
608        let tags = args
609            .get("tags")
610            .and_then(|v| v.as_array())
611            .ok_or_else(|| wm_core::CoreError::InvalidArgs("Missing 'tags' array".into()))?;
612        let tag_list: Vec<String> = tags
613            .iter()
614            .filter_map(|t| t.as_str().map(String::from))
615            .collect();
616        match action {
617            "remove" => {
618                mem.metadata.tags.retain(|t| !tag_list.contains(t));
619            }
620            _ => {
621                for t in &tag_list {
622                    if !mem.metadata.tags.contains(t) {
623                        mem.metadata.tags.push(t.clone());
624                    }
625                }
626            }
627        }
628        self.store.put(galaxy, &mem)?;
629        Ok(json!({
630            "status": "success",
631            "id": mem.metadata.id,
632            "action": action,
633            "tags": mem.metadata.tags,
634        }))
635    }
636    fn stats(&self) -> &ToolStats {
637        &self.stats
638    }
639}
640
641/// `memory.stats` — statistics for a galaxy.
642pub struct MemoryStatsTool {
643    store: Arc<MemoryStore>,
644    stats: ToolStats,
645    effects: EffectRow,
646}
647
648impl MemoryStatsTool {
649    pub fn new(store: Arc<MemoryStore>) -> Self {
650        Self {
651            store,
652            stats: ToolStats::default(),
653            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
654        }
655    }
656}
657
658#[async_trait]
659impl Tool for MemoryStatsTool {
660    fn name(&self) -> &str {
661        "memory.stats"
662    }
663    fn gana(&self) -> Gana {
664        Gana::WinnowingBasket
665    }
666    fn effects(&self) -> &EffectRow {
667        &self.effects
668    }
669    fn description(&self) -> &str {
670        "Statistics for a galaxy (count, avg importance, tag frequency)"
671    }
672    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
673        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
674        let memories = self.store.scan(galaxy, 10_000)?;
675        let total = memories.len();
676        let avg_importance = if total > 0 {
677            memories.iter().map(|m| m.metadata.importance).sum::<f32>() / total as f32
678        } else {
679            0.0
680        };
681        let mut tag_freq: HashMap<String, u32> = HashMap::new();
682        for mem in &memories {
683            for tag in &mem.metadata.tags {
684                *tag_freq.entry(tag.clone()).or_insert(0) += 1;
685            }
686        }
687        let top_tags: Vec<(String, u32)> = tag_freq.into_iter().filter(|(_, c)| *c >= 2).collect();
688        Ok(json!({
689            "status": "success",
690            "galaxy": galaxy_name(galaxy),
691            "count": total,
692            "avg_importance": (avg_importance * 100.0).round() / 100.0,
693            "tag_clusters": top_tags.len(),
694            "top_tags": top_tags.into_iter().take(10).collect::<Vec<_>>(),
695        }))
696    }
697    fn stats(&self) -> &ToolStats {
698        &self.stats
699    }
700}
701
702/// Shared retrieval implementation used by `memory.search` (public verb)
703/// and `memory.hybrid_recall` (compatibility alias).
704pub struct MemoryHybridRecallTool {
705    store: Arc<MemoryStore>,
706    search: Option<Arc<SearchEngine>>,
707    recall: Option<Arc<RecallEngine>>,
708    associations: Option<Arc<AssociationStore>>,
709    stats: ToolStats,
710    effects: EffectRow,
711    route_name: &'static str,
712}
713
714impl MemoryHybridRecallTool {
715    pub fn new(
716        store: Arc<MemoryStore>,
717        search: Option<Arc<SearchEngine>>,
718        recall: Option<Arc<RecallEngine>>,
719    ) -> Self {
720        Self::named("memory.hybrid_recall", store, search, recall)
721    }
722
723    /// Public retrieval verb. Same implementation as `memory.hybrid_recall`.
724    pub fn as_search(
725        store: Arc<MemoryStore>,
726        search: Option<Arc<SearchEngine>>,
727        recall: Option<Arc<RecallEngine>>,
728    ) -> Self {
729        Self::named("memory.search", store, search, recall)
730    }
731
732    /// Attach the association graph for bounded spreading activation:
733    /// top results seed a one-hop expansion over typed links, surfacing
734    /// connected memories that lexical search alone cannot reach.
735    #[must_use]
736    pub fn with_associations(mut self, associations: Option<Arc<AssociationStore>>) -> Self {
737        self.associations = associations;
738        self
739    }
740
741    fn named(
742        route_name: &'static str,
743        store: Arc<MemoryStore>,
744        search: Option<Arc<SearchEngine>>,
745        recall: Option<Arc<RecallEngine>>,
746    ) -> Self {
747        Self {
748            store,
749            search,
750            recall,
751            associations: None,
752            stats: ToolStats::default(),
753            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
754            route_name,
755        }
756    }
757}
758
759/// Build the empty-result guidance message: name where the content actually
760/// lives so callers do not hit the "silent zero" class of failure (e.g.
761/// stores whose memories live in `sessions`/`research`, not the default
762/// `codex`).
763fn empty_result_hint(store: &MemoryStore, galaxy: Galaxy) -> String {
764    let mut populated: Vec<String> = Vec::new();
765    let mut requested_total = 0usize;
766    for g in Galaxy::memory_galaxies() {
767        if g == galaxy {
768            requested_total = store.count(g).unwrap_or(0);
769            continue;
770        }
771        let n = store.count(g).unwrap_or(0);
772        if n > 0 {
773            populated.push(format!("{} ({})", g.db_name(), n));
774        }
775    }
776    let location = if requested_total == 0 {
777        format!("galaxy '{}' contains no memories", galaxy_name(galaxy))
778    } else {
779        format!(
780            "no matches for this query in '{}' ({} memories)",
781            galaxy_name(galaxy),
782            requested_total
783        )
784    };
785    if populated.is_empty() {
786        format!("{location}; the store is empty")
787    } else {
788        format!(
789            "{}; other galaxies with content: {}. Pass an explicit \"galaxy\" to search there.",
790            location,
791            populated.join(", ")
792        )
793    }
794}
795
796/// Same guidance for the galaxy-unfiltered search (no `galaxy` argument):
797/// the query ran everywhere, so the hint reports the overall corpus shape.
798fn empty_result_hint_all(store: &MemoryStore) -> String {
799    let mut populated: Vec<String> = Vec::new();
800    let mut total = 0usize;
801    for g in Galaxy::memory_galaxies() {
802        let n = store.count(g).unwrap_or(0);
803        total += n;
804        if n > 0 {
805            populated.push(format!("{} ({})", g.db_name(), n));
806        }
807    }
808    if populated.is_empty() {
809        "no matches for this query; the store is empty".to_string()
810    } else {
811        format!(
812            "no matches for this query across all memory galaxies ({} total); populated: {}",
813            total,
814            populated.join(", ")
815        )
816    }
817}
818
819#[async_trait]
820impl Tool for MemoryHybridRecallTool {
821    fn name(&self) -> &str {
822        self.route_name
823    }
824    fn gana(&self) -> Gana {
825        Gana::WinnowingBasket
826    }
827    fn effects(&self) -> &EffectRow {
828        &self.effects
829    }
830    fn description(&self) -> &str {
831        "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."
832    }
833    fn input_schema(&self) -> Value {
834        schema(
835            &json!({
836                "query": str_prop("Full-text query"),
837                "galaxy": str_prop("Galaxy filter (optional; default: search all memory galaxies, results labeled)"),
838                "limit": int_prop("Maximum results (default 10)"),
839                "min_importance": num_prop("Minimum memory importance (0-1)"),
840                "min_score": num_prop("Absolute BM25 score floor"),
841                "min_score_ratio": num_prop("Relative floor: reject hits below this fraction of the top score"),
842                "min_trust": num_prop("Minimum source_trust (0-1): drop results below this trust floor"),
843            }),
844            &["query"],
845        )
846    }
847    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
848        let galaxy_explicit = args.get("galaxy").and_then(|v| v.as_str()).is_some();
849        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
850        let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
851        let limit = args
852            .get("limit")
853            .and_then(serde_json::Value::as_u64)
854            .unwrap_or(10) as usize;
855        let min_importance = args
856            .get("min_importance")
857            .and_then(serde_json::Value::as_f64)
858            .unwrap_or(0.0) as f32;
859        // Absolute BM25 floor (0 / absent = disabled). Clients that set a
860        // meaningful `minScore` finally get what they asked for.
861        let min_score = args
862            .get("min_score")
863            .and_then(serde_json::Value::as_f64)
864            .map(|v| v as f32)
865            .filter(|v| *v > 0.0);
866        // Relative floor: reject hits below `ratio * top_score`.
867        // 0.0 or absent → use default 5%. Explicitly passing a value in
868        // (0, 1) overrides; passing 0.0 disables the floor entirely.
869        let min_score_ratio = args
870            .get("min_score_ratio")
871            .and_then(serde_json::Value::as_f64)
872            .map(|v| v as f32)
873            .filter(|v| *v >= 0.0 && *v < 1.0)
874            .map_or(Some(0.05), Some);
875        let mut results = Vec::new();
876
877        // V8.1 trust weighting (evidence-gated): 0.0 = off by default.
878        // See wm_memory::trust_weighted_score — enable after the recall
879        // benchmark re-run, once heritage source_trust stamps are corrected
880        // (wm trust survey / wm trust correct).
881        let trust_weight = std::env::var("WM_TRUST_WEIGHT")
882            .ok()
883            .and_then(|v| v.parse::<f32>().ok())
884            .unwrap_or(0.0)
885            .clamp(0.0, 1.0);
886        // V8 S8 disclosures, populated by Phase 0 when applicable.
887        let mut result_extra: Option<serde_json::Value> = None;
888        let mut trust_disclosure: Option<serde_json::Value> = None;
889
890        // Recall-mode honesty (V8 ship list #1/#6): which route answered
891        // this query is disclosed on the result — hybrid | episodic | fts |
892        // importance | none.
893        let mut recall_mode = "none";
894        // Hybrid fusion requires a REAL embedder: with the stub, vector
895        // halves are noise, so a stub-wired engine must not claim the
896        // hybrid route (the server already refuses to wire one; this gate
897        // makes the tool honest even when constructed directly).
898        let hybrid_available = self
899            .recall
900            .as_ref()
901            .is_some_and(|recall| recall.embedder_is_real());
902
903        // Phase 0: If RecallEngine with a real embedder is available, use
904        // hybrid BM25 + vector search for fused ranking. Trust weighting
905        // lives INSIDE the fusion since V8 S8 (single application point —
906        // applying it again here would double-count); the per-result
907        // trust_factor + conformal set disclosure come straight from the
908        // engine.
909        if hybrid_available {
910            let recall = self.recall.as_ref().expect("hybrid_available checked");
911            if !query.is_empty() {
912                let (hybrid_results, conformal) = recall.hybrid_search_with_disclosure(
913                    query,
914                    limit * 2,
915                    galaxy_explicit.then_some(galaxy),
916                );
917                for hr in hybrid_results {
918                    if let Ok(Some(mem)) = self.store.get(hr.galaxy, hr.memory_id) {
919                        if mem.metadata.importance >= min_importance
920                            && crate::expansion::common::mcp_visible(&mem)
921                            && crate::expansion::common::validity_visible(&mem)
922                        {
923                            results.push(json!({
924                                "id": mem.metadata.id,
925                                "galaxy": mem.metadata.galaxy.db_name(),
926                                "content": wm_memory::scrub_text(&mem.content),
927                                "importance": mem.metadata.importance,
928                                "score": hr.score,
929                                "trust_factor": hr.trust_factor,
930                                "corroboration": hr.corroboration,
931                                "in_conformal_set": hr.in_conformal_set,
932                                "bm25_score": hr.bm25_score,
933                                "vector_score": hr.vector_score,
934                                "trust": mem.metadata.source_trust,
935                                "source": "hybrid",
936                            }));
937                        }
938                    }
939                }
940                // Set-level calibrated coverage disclosure (V8 S8) —
941                // attached whenever conformal mode is configured, honest
942                // about `uncalibrated` until feedback exists.
943                if let Some(info) = conformal {
944                    result_extra = serde_json::to_value(&info).ok();
945                }
946                if trust_weight > 0.0 {
947                    trust_disclosure = Some(json!({
948                        "wm_trust_weight": trust_weight,
949                        "applied_in": "fuse_results",
950                    }));
951                }
952                if !results.is_empty() {
953                    recall_mode = "hybrid";
954                }
955            }
956        }
957
958        // Phase E: the episodic deterministic route (V8 ship list #1) —
959        // preferred over plain FTS whenever the hybrid route is
960        // unavailable. The episodic lane mirrors every v5 write
961        // (capture_explicit_memory), its deterministic scorer measures
962        // R@1 0.86 vs the BM25 fallback's 0.64 (LongMemEval-S 50q, S8
963        // protocol 2026-09-01), and this wire is exactly the v26
964        // "one fast brain" lesson: route to the best machinery by
965        // default, disclose which one ran. Falls through to FTS only
966        // when episodic yields nothing (legacy stores, empty lane,
967        // genuine no-match). Pool 100 mirrors the acceptance protocol
968        // (retrieve broad, truncate to `limit` below).
969        if results.is_empty() && !query.is_empty() && !hybrid_available {
970            const EPISODIC_RECALL_POOL: usize = 100;
971            let pool = limit.max(EPISODIC_RECALL_POOL);
972            // Degradation is never fatal: an episodic-lane error falls
973            // through to the FTS phases like an empty lane would.
974            let episodic_hits = match self
975                .store
976                .episodic()
977                .search_with_limits(query, pool, pool, false)
978            {
979                Ok(hits) => hits,
980                Err(error) => {
981                    tracing::warn!("episodic default-route search failed: {error}");
982                    Vec::new()
983                }
984            };
985            for er in episodic_hits {
986                // Record ids mirror the v5 memory id; resolve to carry
987                // galaxy/importance/visibility from the source of truth.
988                let Some((hit_galaxy, mem)) =
989                    resolve_memory_across_galaxies(&self.store, er.record.id)
990                else {
991                    continue;
992                };
993                if galaxy_explicit && hit_galaxy != galaxy {
994                    continue;
995                }
996                if mem.metadata.importance < min_importance
997                    || !crate::expansion::common::mcp_visible(&mem)
998                    || !crate::expansion::common::validity_visible(&mem)
999                {
1000                    continue;
1001                }
1002                results.push(json!({
1003                    "id": mem.metadata.id,
1004                    "galaxy": hit_galaxy.db_name(),
1005                    "content": wm_memory::scrub_text(&mem.content),
1006                    "importance": mem.metadata.importance,
1007                    "score": er.score,
1008                    "matched_terms": er.matched_terms,
1009                    "trust": mem.metadata.source_trust,
1010                    "source": "episodic",
1011                }));
1012            }
1013            if !results.is_empty() {
1014                recall_mode = "episodic";
1015            }
1016        }
1017
1018        // Phase 1: full-text search (OR + token-coverage + score floors)
1019        // Only run if hybrid search didn't produce results or no RecallEngine
1020        if results.is_empty() {
1021            if let Some(ref search) = self.search {
1022                if !query.is_empty() {
1023                    let opts = wm_memory::SearchOptions {
1024                        limit: limit * 2,
1025                        min_score,
1026                        relative_floor: min_score_ratio,
1027                        // Explicit galaxy filters at the index. Without one,
1028                        // the query runs across every memory galaxy — the
1029                        // Tantivy query was always galaxy-blind, but hits
1030                        // were resolved against the default galaxy only,
1031                        // which silently hid sessions/research/dreams
1032                        // content (found by the post-cutover federated
1033                        // verification, 2026-08-29).
1034                        galaxy: galaxy_explicit.then_some(galaxy),
1035                        ..wm_memory::SearchOptions::default()
1036                    };
1037                    let hits = search.search_opt(query, &opts)?;
1038                    for hit in hits {
1039                        if let Ok(id) = uuid::Uuid::parse_str(&hit.memory_id) {
1040                            // Resolve each hit in the galaxy its index
1041                            // document declares — with no explicit filter
1042                            // this is the only correct resolution.
1043                            let hit_galaxy = if galaxy_explicit {
1044                                Some(galaxy)
1045                            } else {
1046                                wm_core::Galaxy::all()
1047                                    .into_iter()
1048                                    .find(|g| g.db_name() == hit.galaxy)
1049                            };
1050                            let Some(hit_galaxy) = hit_galaxy else {
1051                                continue;
1052                            };
1053                            if let Ok(Some(mem)) = self.store.get(hit_galaxy, id) {
1054                                if mem.metadata.importance >= min_importance
1055                                    && crate::expansion::common::mcp_visible(&mem)
1056                                    && crate::expansion::common::validity_visible(&mem)
1057                                {
1058                                    results.push(json!({
1059                                            "id": mem.metadata.id,
1060                                            "galaxy": hit_galaxy.db_name(),
1061                                            "content": wm_memory::scrub_text(&mem.content),
1062                                            "importance": mem.metadata.importance,
1063                                            "score": wm_memory::trust_weighted_score(
1064                                                hit.score,
1065                                                mem.metadata.source_trust,
1066                                                trust_weight,
1067                                            ),
1068                                            "normalized_score": hit.normalized_score,
1069                                        "trust": mem.metadata.source_trust,
1070                                        "source": "fts",
1071                                    }));
1072                                    if recall_mode == "none" {
1073                                        recall_mode = "fts";
1074                                    }
1075                                }
1076                            }
1077                        }
1078                    }
1079                }
1080            }
1081        }
1082        // Phase 2: only when NO query was given, return by importance.
1083        // (With a query, empty results are final — a score threshold must
1084        // not be bypassed by a scan-based fallback.)
1085        if results.is_empty() && query.is_empty() {
1086            let mut memories = self.store.scan(galaxy, 100)?;
1087            memories.sort_by(|a, b| {
1088                b.metadata
1089                    .importance
1090                    .partial_cmp(&a.metadata.importance)
1091                    .unwrap_or(std::cmp::Ordering::Equal)
1092            });
1093            for mem in memories
1094                .iter()
1095                .filter(|m| {
1096                    m.metadata.importance >= min_importance
1097                        && crate::expansion::common::mcp_visible(m)
1098                        && crate::expansion::common::validity_visible(m)
1099                })
1100                .take(limit)
1101            {
1102                results.push(json!({
1103                    "id": mem.metadata.id,
1104                    "content": &mem.content,
1105                    "importance": mem.metadata.importance,
1106                    "score": mem.metadata.importance,
1107                    "source": "importance",
1108                }));
1109                if recall_mode == "none" {
1110                    recall_mode = "importance";
1111                }
1112            }
1113        }
1114        // Phase 3: bounded spreading activation over the association graph.
1115        // Top seeds from Phases 0-2 activate their one-hop neighbors; neighbors
1116        // surface as discounted results marked source=association. Read-only:
1117        // no Hebbian writes, one hop, at most 5 expansions.
1118        if !results.is_empty() {
1119            if let Some(assoc_store) = &self.associations {
1120                let mut anchors: Vec<(uuid::Uuid, f32)> = results
1121                    .iter()
1122                    .filter_map(|r| {
1123                        let id = r.get("id")?.as_str()?;
1124                        uuid::Uuid::parse_str(id).ok().map(|u| {
1125                            (
1126                                u,
1127                                r.get("score")
1128                                    .and_then(serde_json::Value::as_f64)
1129                                    .unwrap_or(0.0) as f32,
1130                            )
1131                        })
1132                    })
1133                    .collect();
1134                anchors.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1135                anchors.dedup_by(|a, b| a.0 == b.0);
1136                anchors.truncate(5);
1137
1138                let mut expansions: Vec<(uuid::Uuid, f32, f32, String, uuid::Uuid)> = Vec::new();
1139                for (seed_id, seed_score) in &anchors {
1140                    let mut links = Vec::new();
1141                    if let Ok(outgoing) = assoc_store.find_from(self.store.env(), *seed_id) {
1142                        links.extend(outgoing);
1143                    }
1144                    if let Ok(incoming) = assoc_store.find_to(self.store.env(), *seed_id) {
1145                        links.extend(incoming);
1146                    }
1147                    for assoc in links {
1148                        if assoc.weight < 0.05 {
1149                            continue;
1150                        }
1151                        let neighbor = if assoc.target == *seed_id {
1152                            assoc.source
1153                        } else {
1154                            assoc.target
1155                        };
1156                        let score = seed_score * assoc.weight * 0.5;
1157                        if score <= 0.0 {
1158                            continue;
1159                        }
1160                        expansions.push((
1161                            neighbor,
1162                            score,
1163                            assoc.weight,
1164                            assoc.link_type.as_str().to_string(),
1165                            *seed_id,
1166                        ));
1167                    }
1168                }
1169                expansions.sort_by(|a, b| {
1170                    b.1.partial_cmp(&a.1)
1171                        .unwrap_or(std::cmp::Ordering::Equal)
1172                        .then_with(|| a.0.cmp(&b.0))
1173                });
1174                expansions.dedup_by(|a, b| a.0 == b.0);
1175                expansions.truncate(5);
1176
1177                let direct_ids: Vec<String> = results
1178                    .iter()
1179                    .filter_map(|r| {
1180                        r.get("id")
1181                            .and_then(serde_json::Value::as_str)
1182                            .map(String::from)
1183                    })
1184                    .collect();
1185                for (neighbor_id, score, weight, link_type, seed_id) in expansions {
1186                    if direct_ids.iter().any(|id| id == &neighbor_id.to_string()) {
1187                        continue;
1188                    }
1189                    let Some((_, mem)) = resolve_memory_across_galaxies(&self.store, neighbor_id)
1190                    else {
1191                        continue;
1192                    };
1193                    if mem.metadata.importance < min_importance
1194                        || !crate::expansion::common::mcp_visible(&mem)
1195                        || !crate::expansion::common::validity_visible(&mem)
1196                    {
1197                        continue;
1198                    }
1199                    results.push(json!({
1200                        "id": mem.metadata.id,
1201                        "content": wm_memory::scrub_text(&mem.content),
1202                        "importance": mem.metadata.importance,
1203                        "score": score,
1204                        "weight": weight,
1205                        "link_type": link_type,
1206                        "via": seed_id.to_string(),
1207                        "source": "association",
1208                    }));
1209                }
1210            }
1211        }
1212        // Trust weighting re-orders (Phase 1 pushed in Tantivy's
1213        // unweighted order); re-sort so the cut at `limit` is honest.
1214        if trust_weight > 0.0 {
1215            results.sort_by(|a, b| {
1216                b.get("score")
1217                    .and_then(serde_json::Value::as_f64)
1218                    .partial_cmp(&a.get("score").and_then(serde_json::Value::as_f64))
1219                    .unwrap_or(std::cmp::Ordering::Equal)
1220            });
1221        }
1222        // V8 T-b: explicit trust floor — a FILTER, not a ranking weight
1223        // (WM_TRUST_WEIGHT reorders; min_trust removes). Post-resolution
1224        // on every route: results carry `trust`, so the floor applies
1225        // uniformly, including the trust-inert episodic route.
1226        let min_trust = args
1227            .get("min_trust")
1228            .and_then(serde_json::Value::as_f64)
1229            .filter(|v| (0.0..=1.0).contains(v));
1230        let pre_filter = results.len();
1231        if let Some(min) = min_trust {
1232            results.retain(
1233                |r| match r.get("trust").and_then(serde_json::Value::as_f64) {
1234                    Some(t) => (t as f32) >= min as f32,
1235                    None => true,
1236                },
1237            );
1238        }
1239        let min_trust_filtered = pre_filter - results.len();
1240        results.truncate(limit);
1241        // Empty-result guidance: when a query matched nothing, tell the caller
1242        // where the content actually lives. Prevents the "silent zero" class
1243        // of failure (e.g. stores like the vault whose memories live in
1244        // `sessions`/`research`, not the default `codex`).
1245        let hint = if results.is_empty() && !query.is_empty() {
1246            Some(if galaxy_explicit {
1247                empty_result_hint(&self.store, galaxy)
1248            } else {
1249                empty_result_hint_all(&self.store)
1250            })
1251        } else {
1252            None
1253        };
1254        let mut out = json!({
1255            "status": "success",
1256            "galaxy": if galaxy_explicit {
1257                serde_json::Value::from(galaxy_name(galaxy))
1258            } else {
1259                serde_json::Value::from("all")
1260            },
1261            "count": results.len(),
1262            "recall_mode": recall_mode,
1263            "results": results,
1264            "hint": hint,
1265        });
1266        // V8 S8 disclosures: the conformal set claim (active/uncalibrated)
1267        // and, when the trust knob is on, where the weighting was applied.
1268        if let Some(extra) = result_extra {
1269            out["conformal_set"] = extra;
1270        }
1271        if let Some(td) = trust_disclosure {
1272            out["trust_weighting"] = td;
1273        }
1274        if let Some(min) = min_trust {
1275            out["min_trust"] = json!(min);
1276            out["min_trust_filtered"] = json!(min_trust_filtered);
1277        }
1278        Ok(out)
1279    }
1280    fn stats(&self) -> &ToolStats {
1281        &self.stats
1282    }
1283}
1284
1285/// `memory.recall_feedback` — record relevance feedback into the recall
1286/// engine's conformal calibrator (V8 S8).
1287///
1288/// This is how retrieval earns the right to claim coverage: explicit
1289/// labels (human feedback or a harness with ground truth) become
1290/// calibration samples; results then carry set membership against a
1291/// threshold with a real guarantee. Refuses honestly when
1292/// `WM_RECALL_CONFORMAL_ALPHA` is unset — there is no calibrated set to
1293/// feed.
1294pub struct MemoryRecallFeedbackTool {
1295    recall: Option<Arc<RecallEngine>>,
1296    stats: ToolStats,
1297    effects: EffectRow,
1298}
1299
1300impl MemoryRecallFeedbackTool {
1301    #[must_use]
1302    pub fn new(recall: Option<Arc<RecallEngine>>) -> Self {
1303        Self {
1304            recall,
1305            stats: ToolStats::default(),
1306            // Persists the fitted classifier JSON to the store root when
1307            // the calibrator crosses its fit threshold — a filesystem
1308            // write outside LMDB, declared as a capability (usage is
1309            // conditional on WM_RECALL_CONFORMAL_ALPHA + fit state).
1310            effects: EffectRow {
1311                writes: vec![Resource::Filesystem],
1312                ..Default::default()
1313            },
1314        }
1315    }
1316}
1317
1318#[async_trait]
1319impl Tool for MemoryRecallFeedbackTool {
1320    fn name(&self) -> &str {
1321        "memory.recall_feedback"
1322    }
1323    fn gana(&self) -> Gana {
1324        Gana::WinnowingBasket
1325    }
1326    fn effects(&self) -> &EffectRow {
1327        &self.effects
1328    }
1329    fn description(&self) -> &str {
1330        "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."
1331    }
1332    fn input_schema(&self) -> Value {
1333        schema(
1334            &json!({
1335                "samples": {"type": "array", "description": "Feedback samples: [{score: 0-1 fused score, relevant: bool}]"},
1336                "score": num_prop("Single-sample fused score (0-1)"),
1337                "relevant": {"type": "boolean", "description": "Single-sample relevance label"},
1338            }),
1339            &[],
1340        )
1341    }
1342    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1343        let Some(ref recall) = self.recall else {
1344            return Ok(json!({
1345                "status": "error",
1346                "message": "no recall engine on this server (hybrid search unavailable) — nothing to calibrate",
1347            }));
1348        };
1349        let mut samples: Vec<(f32, bool)> = Vec::new();
1350        if let Some(list) = args.get("samples").and_then(Value::as_array) {
1351            for s in list {
1352                let score = s.get("score").and_then(Value::as_f64).unwrap_or(-1.0);
1353                let relevant = s.get("relevant").and_then(Value::as_bool);
1354                if !(0.0..=1.0).contains(&score) || relevant.is_none() {
1355                    return Err(wm_core::CoreError::InvalidArgs(
1356                        "each sample needs score in [0,1] and a boolean 'relevant'".into(),
1357                    ));
1358                }
1359                samples.push((score as f32, relevant.unwrap_or(false)));
1360            }
1361        } else if let Some(score) = args.get("score").and_then(Value::as_f64) {
1362            let relevant = args
1363                .get("relevant")
1364                .and_then(Value::as_bool)
1365                .ok_or_else(|| {
1366                    wm_core::CoreError::InvalidArgs("'relevant' is required with 'score'".into())
1367                })?;
1368            if !(0.0..=1.0).contains(&score) {
1369                return Err(wm_core::CoreError::InvalidArgs(
1370                    "'score' must be within [0,1]".into(),
1371                ));
1372            }
1373            samples.push((score as f32, relevant));
1374        } else {
1375            return Err(wm_core::CoreError::InvalidArgs(
1376                "provide 'samples' (array of {score, relevant}) or a single 'score' + 'relevant'"
1377                    .into(),
1378            ));
1379        }
1380
1381        let mut recorded = 0usize;
1382        let mut count = 0usize;
1383        for (score, relevant) in samples {
1384            count = recall.record_relevance_feedback(score, relevant)?;
1385            recorded += 1;
1386        }
1387        // Honest post-state disclosure so the caller can see whether the
1388        // calibrator crossed its fit threshold.
1389        let status = recall
1390            .conformal_disclosure(&mut Vec::new())?
1391            .map_or_else(|| "off".into(), |info| info.status);
1392        Ok(json!({
1393            "status": "success",
1394            "recorded": recorded,
1395            "calibration_samples": count,
1396            "conformal_status": status,
1397        }))
1398    }
1399    fn stats(&self) -> &ToolStats {
1400        &self.stats
1401    }
1402}
1403
1404/// `memory.episodic_search` — v6 raw episodic search for controlled evaluation.
1405///
1406/// This route is explicit-only and is not part of the curated v5 surface.
1407pub struct MemoryEpisodicSearchTool {
1408    store: Arc<MemoryStore>,
1409    stats: ToolStats,
1410    effects: EffectRow,
1411}
1412
1413impl MemoryEpisodicSearchTool {
1414    pub fn new(store: Arc<MemoryStore>) -> Self {
1415        Self {
1416            store,
1417            stats: ToolStats::default(),
1418            effects: EffectRow::read_only(vec![Resource::Galaxy("episodic_records".into())]),
1419        }
1420    }
1421}
1422
1423#[async_trait]
1424impl Tool for MemoryEpisodicSearchTool {
1425    fn name(&self) -> &str {
1426        "memory.episodic_search"
1427    }
1428    fn gana(&self) -> Gana {
1429        Gana::WinnowingBasket
1430    }
1431    fn effects(&self) -> &EffectRow {
1432        &self.effects
1433    }
1434    fn description(&self) -> &str {
1435        "[V6 Experimental] Search explicit episodic records with provenance and lifecycle filtering"
1436    }
1437    fn input_schema(&self) -> Value {
1438        schema(
1439            &json!({
1440                "query": str_prop("Full-text query"),
1441                "limit": int_prop("Maximum results (default 10)"),
1442                "candidate_limit": int_prop("Maximum candidates to score (default 2x limit)"),
1443                "include_historical": {
1444                    "type": "boolean",
1445                    "description": "Include superseded, revoked, and archived records",
1446                },
1447                "rerank": {
1448                    "type": "boolean",
1449                    "description": "Enable vector reranking (requires embedder, default false)",
1450                },
1451                "rerank_alpha": {
1452                    "type": "number",
1453                    "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)",
1454                },
1455                "min_score": {
1456                    "type": "number",
1457                    "description": "Minimum score threshold; results below this are dropped (abstention). Default 0.0 (no threshold)",
1458                },
1459                "min_coverage": {
1460                    "type": "number",
1461                    "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)",
1462                },
1463            }),
1464            &["query"],
1465        )
1466    }
1467    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1468        let query = args.get("query").and_then(Value::as_str).unwrap_or("");
1469        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
1470        let candidate_limit =
1471            args.get("candidate_limit")
1472                .and_then(Value::as_u64)
1473                .unwrap_or_else(|| limit.saturating_mul(2) as u64) as usize;
1474        let include_historical = args
1475            .get("include_historical")
1476            .and_then(Value::as_bool)
1477            .unwrap_or(false);
1478        let rerank = args.get("rerank").and_then(Value::as_bool).unwrap_or(false);
1479        let rerank_alpha = args
1480            .get("rerank_alpha")
1481            .and_then(Value::as_f64)
1482            .unwrap_or(0.7) as f32;
1483        let min_score = args
1484            .get("min_score")
1485            .and_then(Value::as_f64)
1486            .map(|v| v as f32);
1487        let min_coverage = args
1488            .get("min_coverage")
1489            .and_then(Value::as_f64)
1490            .map(|v| v as f32);
1491        // Compute query content-term count for coverage ratio.
1492        // We use a simple split on non-alphanumeric after removing common
1493        // stopwords, matching the episodic search tokenization.
1494        let query_term_count: usize = {
1495            const STOPWORDS: &[&str] = &[
1496                "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has",
1497                "had", "do", "does", "did", "will", "would", "could", "should", "may", "might",
1498                "must", "can", "shall", "to", "of", "in", "on", "at", "by", "for", "with", "about",
1499                "as", "into", "like", "through", "after", "over", "between", "out", "against",
1500                "during", "without", "before", "under", "around", "among", "i", "me", "my", "we",
1501                "us", "our", "you", "your", "he", "him", "his", "she", "her", "it", "its", "they",
1502                "them", "their", "what", "whats", "who", "when", "where", "why", "how", "and",
1503                "or", "but", "not", "no", "nor", "so", "yet", "both", "either", "neither", "this",
1504                "that", "these", "those", "there", "here", "now", "then", "than",
1505            ];
1506            query
1507                .split(|c: char| !c.is_alphanumeric())
1508                .filter(|t| t.len() > 1)
1509                .map(str::to_ascii_lowercase)
1510                .filter(|t| !STOPWORDS.contains(&t.as_str()))
1511                .collect::<std::collections::HashSet<_>>()
1512                .len()
1513        };
1514        let raw_results = if rerank {
1515            self.store.episodic().search_with_rerank(
1516                query,
1517                limit,
1518                candidate_limit,
1519                include_historical,
1520                rerank_alpha,
1521            )?
1522        } else {
1523            self.store.episodic().search_with_limits(
1524                query,
1525                limit,
1526                candidate_limit,
1527                include_historical,
1528            )?
1529        };
1530        // Coverage-based abstention: if the query has 3+ content terms and
1531        // NO result matches 2+ terms, all matches are likely on a single
1532        // generic term (e.g. "favorite") rather than the actual topic.
1533        // In that case, abstain entirely. If even one result matches 2+
1534        // terms, keep all results (the query has real matches in the haystack).
1535        // Skip abstention for count-style queries ("how many") since they
1536        // need all candidates for count verification.
1537        let is_count_query = query.to_ascii_lowercase().contains("how many");
1538        let abstain = min_coverage.is_some()
1539            && !is_count_query
1540            && query_term_count >= 3
1541            && !raw_results.iter().any(|hit| hit.matched_terms >= 2);
1542        let visible: Vec<_> = raw_results
1543            .into_iter()
1544            .filter(|hit| !hit.record.is_private && !hit.record.model_exclude)
1545            .filter(|hit| min_score.is_none_or(|ms| hit.score >= ms))
1546            .filter(|_| !abstain)
1547            .take(limit)
1548            .collect();
1549        // Read-time contradiction detection over the visible results only
1550        // (TANGLE semantics: surface both sides with provenance, never
1551        // silently resolve).
1552        let conflicts = detect_conflicts(&visible);
1553        let results = visible
1554            .into_iter()
1555            .map(|hit| {
1556                json!({
1557                    "id": hit.record.id,
1558                    "content": wm_memory::scrub_text(&hit.record.content),
1559                    "score": hit.score,
1560                    "matched_terms": hit.matched_terms,
1561                    "session_id": hit.record.session_id,
1562                    "sequence": hit.record.sequence,
1563                    "created_at": hit.record.created_at,
1564                    "validity": hit.record.validity,
1565                    "provenance": hit.record.provenance,
1566                    "source": "episodic",
1567                })
1568            })
1569            .collect::<Vec<_>>();
1570        Ok(json!({
1571            "status": "success",
1572            "count": results.len(),
1573            // Temporal resolution: true when the query asked for the
1574            // current/latest value and the topic cluster was reordered by
1575            // deterministic chronology (see episodic::resolve_current).
1576            "current_resolution": wm_memory::episodic::is_current_query(query),
1577            // Detected contradictions among the results, when any: both
1578            // statements with provenance; the caller decides (TANGLE).
1579            "conflicts": conflicts,
1580            "results": results,
1581        }))
1582    }
1583    fn stats(&self) -> &ToolStats {
1584        &self.stats
1585    }
1586}
1587
1588/// `memory.aggregate` — post-retrieval aggregation over matching memories.
1589///
1590/// Retrieves memories with a full-text query (same BM25 path as
1591/// `memory.search`) and computes an aggregate over the results. Session
1592/// metrics derive from `session_<n>` tags (a common client convention),
1593/// letting callers answer cross-session synthesis questions like "how long
1594/// from X to Y" without scanning raw results themselves.
1595///
1596/// For span metrics, the anchor set is narrowed to results matching the
1597/// rarest query term (fewest matches, ties broken by query order) so that
1598/// unrelated-but-similar turns (e.g. the same question about a different
1599/// skill) do not distort the span.
1600pub struct MemoryAggregateTool {
1601    search: Option<Arc<SearchEngine>>,
1602    store: Arc<MemoryStore>,
1603    stats: ToolStats,
1604    effects: EffectRow,
1605}
1606
1607impl MemoryAggregateTool {
1608    pub fn new(search: Option<Arc<SearchEngine>>, store: Arc<MemoryStore>) -> Self {
1609        Self {
1610            search,
1611            store,
1612            stats: ToolStats::default(),
1613            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1614        }
1615    }
1616}
1617
1618/// Extract a session ordinal from `session_<n>` tags.
1619fn session_ordinal(tags: &[String]) -> Option<u64> {
1620    tags.iter().find_map(|tag| {
1621        let rest = tag.strip_prefix("session_")?;
1622        rest.parse::<u64>().ok()
1623    })
1624}
1625
1626/// Word-boundary match of a lowercase query term against content (with a
1627/// light suffix-stripped variant for morphological tolerance).
1628fn contains_term(content: &str, term: &str) -> bool {
1629    let lowered = content.to_ascii_lowercase();
1630    let variants = [term.to_string(), strip_suffix(term)];
1631    for variant in &variants {
1632        if variant.len() < 2 {
1633            continue;
1634        }
1635        let mut start = 0;
1636        while let Some(pos) = lowered[start..].find(variant.as_str()) {
1637            let before_ok = pos == 0
1638                || !lowered[start + pos - 1..start + pos]
1639                    .chars()
1640                    .next()
1641                    .is_some_and(char::is_alphanumeric);
1642            let end = start + pos + variant.len();
1643            let after_ok = end >= lowered.len()
1644                || !lowered[end..]
1645                    .chars()
1646                    .next()
1647                    .is_some_and(char::is_alphanumeric);
1648            if before_ok && after_ok {
1649                return true;
1650            }
1651            start += pos + variant.len();
1652        }
1653    }
1654    false
1655}
1656
1657/// Strip a common English suffix for tolerant matching (mirrors the
1658/// simple stemmer used by the search tokenizer).
1659fn strip_suffix(term: &str) -> String {
1660    for suffix in ["ing", "ed", "es", "s"] {
1661        if let Some(stem) = term.strip_suffix(suffix) {
1662            if stem.len() >= 2 {
1663                return stem.to_string();
1664            }
1665        }
1666    }
1667    term.to_string()
1668}
1669
1670#[async_trait]
1671impl Tool for MemoryAggregateTool {
1672    fn name(&self) -> &str {
1673        "memory.aggregate"
1674    }
1675    fn gana(&self) -> Gana {
1676        Gana::WinnowingBasket
1677    }
1678    fn effects(&self) -> &EffectRow {
1679        &self.effects
1680    }
1681    fn description(&self) -> &str {
1682        "Aggregate over memories matching a query: count, distinct session count, or session span (cross-session synthesis)"
1683    }
1684    fn input_schema(&self) -> Value {
1685        schema(
1686            &json!({
1687                "query": str_prop("Full-text query selecting the memories to aggregate over"),
1688                "metric": str_prop("Aggregate metric: count | session_count | session_span"),
1689                "limit": int_prop("Maximum candidates considered (default 50)"),
1690            }),
1691            &["query", "metric"],
1692        )
1693    }
1694    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1695        let query = args
1696            .get("query")
1697            .and_then(Value::as_str)
1698            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
1699        let metric = args
1700            .get("metric")
1701            .and_then(Value::as_str)
1702            .ok_or_else(|| wm_core::CoreError::InvalidArgs("metric (string) required".into()))?;
1703        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(50) as usize;
1704        let Some(search) = self.search.as_ref() else {
1705            return Err(wm_core::CoreError::Memory(
1706                "search engine unavailable for aggregation".into(),
1707            ));
1708        };
1709
1710        let results = search.search(query, limit)?;
1711        // Load full memories (for tags) and drop non-visible ones.
1712        let mut memories = Vec::new();
1713        for r in &results {
1714            let Some(galaxy) = wm_core::Galaxy::from_db_name(&r.galaxy) else {
1715                continue;
1716            };
1717            let Ok(id) = uuid::Uuid::parse_str(&r.memory_id) else {
1718                continue;
1719            };
1720            let Ok(Some(mem)) = self.store.get(galaxy, id) else {
1721                continue;
1722            };
1723            if super::common::mcp_visible(&mem) && super::common::validity_visible(&mem) {
1724                memories.push((r.score, mem));
1725            }
1726        }
1727
1728        let evidence: Vec<Value> = memories
1729            .iter()
1730            .map(|(score, mem)| {
1731                json!({
1732                    "memory_id": mem.metadata.id.to_string(),
1733                    "score": score,
1734                    "content": wm_memory::scrub_text(&mem.content),
1735                    "tags": mem.metadata.tags,
1736                })
1737            })
1738            .collect();
1739
1740        // Anchor narrowing for session metrics: keep only results matching
1741        // the rarest query term (fewest matches; ties by query order).
1742        // Honesty fallbacks (previously both yielded an empty anchor set,
1743        // reporting session_count 0 / span null DESPITE session-tagged
1744        // evidence): with fewer than 2 session-tagged hits there is nothing
1745        // to disambiguate, so the session-tagged set IS the anchor; when no
1746        // term matches (stopword-only query, vocab mismatch) the same
1747        // fallback applies. The `anchor` disclosure says which ran.
1748        let session_tagged: Vec<_> = memories
1749            .iter()
1750            .filter(|(_, mem)| session_ordinal(&mem.metadata.tags).is_some())
1751            .collect();
1752        let (anchored, anchor): (Vec<_>, &str) = if metric == "count" {
1753            (Vec::new(), "none")
1754        } else if session_tagged.len() < 2 {
1755            (session_tagged.clone(), "session_tagged_fallback")
1756        } else {
1757            let terms: Vec<String> = wm_memory::strip_stopwords(query)
1758                .split(|c: char| !c.is_alphanumeric())
1759                .filter(|t| t.len() > 1)
1760                .map(str::to_ascii_lowercase)
1761                .collect();
1762            let mut best: Option<(String, usize)> = None;
1763            for term in &terms {
1764                let count = session_tagged
1765                    .iter()
1766                    .filter(|(_, mem)| contains_term(&mem.content, term))
1767                    .count();
1768                if count == 0 {
1769                    continue;
1770                }
1771                let better = best
1772                    .as_ref()
1773                    .is_none_or(|(_, best_count)| count < *best_count);
1774                if better {
1775                    best = Some((term.clone(), count));
1776                }
1777            }
1778            match best {
1779                Some((term, _)) => (
1780                    session_tagged
1781                        .iter()
1782                        .filter(|(_, mem)| contains_term(&mem.content, &term))
1783                        .copied()
1784                        .collect(),
1785                    "rarest_term",
1786                ),
1787                None => (session_tagged.clone(), "session_tagged_fallback"),
1788            }
1789        };
1790
1791        let aggregate = match metric {
1792            "count" => json!({
1793                "metric": "count",
1794                "value": memories.len(),
1795                "content": format!("{} memories", memories.len()),
1796            }),
1797            "session_count" => {
1798                let sessions: std::collections::HashSet<u64> = anchored
1799                    .iter()
1800                    .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
1801                    .collect();
1802                json!({
1803                    "metric": "session_count",
1804                    "value": sessions.len(),
1805                    "content": format!("{} distinct sessions", sessions.len()),
1806                })
1807            }
1808            "session_span" => {
1809                let ordinals: Vec<u64> = anchored
1810                    .iter()
1811                    .filter_map(|(_, mem)| session_ordinal(&mem.metadata.tags))
1812                    .collect();
1813                if ordinals.is_empty() {
1814                    json!({
1815                        "metric": "session_span",
1816                        "value": null,
1817                        "content": "no session-tagged evidence found",
1818                    })
1819                } else {
1820                    let span = ordinals.iter().max().unwrap() - ordinals.iter().min().unwrap();
1821                    json!({
1822                        "metric": "session_span",
1823                        "value": span,
1824                        "unit": "sessions",
1825                        "content": format!("{span} sessions"),
1826                    })
1827                }
1828            }
1829            other => {
1830                return Err(wm_core::CoreError::InvalidArgs(format!(
1831                    "unknown metric '{other}' (count | session_count | session_span)"
1832                )));
1833            }
1834        };
1835
1836        Ok(json!({
1837            "status": "success",
1838            "query": query,
1839            "total": memories.len(),
1840            "session_tagged": session_tagged.len(),
1841            "anchor": anchor,
1842            "limit_hit": results.len() >= limit,
1843            "aggregate": aggregate,
1844            "results": evidence,
1845        }))
1846    }
1847    fn stats(&self) -> &ToolStats {
1848        &self.stats
1849    }
1850}
1851
1852/// `memory.sort` — sort memories by importance, recency, or access count.
1853pub struct MemorySortTool {
1854    store: Arc<MemoryStore>,
1855    stats: ToolStats,
1856    effects: EffectRow,
1857}
1858
1859impl MemorySortTool {
1860    pub fn new(store: Arc<MemoryStore>) -> Self {
1861        Self {
1862            store,
1863            stats: ToolStats::default(),
1864            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1865        }
1866    }
1867}
1868
1869#[async_trait]
1870impl Tool for MemorySortTool {
1871    fn name(&self) -> &str {
1872        "memory.sort"
1873    }
1874    fn gana(&self) -> Gana {
1875        Gana::WinnowingBasket
1876    }
1877    fn effects(&self) -> &EffectRow {
1878        &self.effects
1879    }
1880    fn description(&self) -> &str {
1881        "Sort memories by importance, recency, or access count"
1882    }
1883    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1884        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
1885        let sort_by = args
1886            .get("sort_by")
1887            .and_then(|v| v.as_str())
1888            .unwrap_or("importance");
1889        let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
1890        let limit = args
1891            .get("limit")
1892            .and_then(serde_json::Value::as_u64)
1893            .unwrap_or(50) as usize;
1894
1895        let mut memories = self.store.scan(galaxy, 10_000)?;
1896        // Private memories never appear in MCP responses. Non-current
1897        // validity likewise hides while enforced (Slice B, off by default).
1898        memories.retain(|m| {
1899            crate::expansion::common::mcp_visible(m)
1900                && crate::expansion::common::validity_visible(m)
1901        });
1902
1903        match sort_by {
1904            "importance" => memories.sort_by(|a, b| {
1905                b.metadata
1906                    .importance
1907                    .partial_cmp(&a.metadata.importance)
1908                    .unwrap_or(std::cmp::Ordering::Equal)
1909            }),
1910            "recency" => memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.created_at)),
1911            "accessed" => {
1912                memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.accessed_at));
1913            }
1914            "access_count" => {
1915                memories.sort_by_key(|x| std::cmp::Reverse(x.metadata.access_count));
1916            }
1917            _ => {
1918                return Err(wm_core::CoreError::InvalidArgs(format!(
1919                    "Unknown sort_by: '{sort_by}'. Use importance, recency, accessed, or access_count"
1920                )));
1921            }
1922        }
1923
1924        if order == "asc" {
1925            memories.reverse();
1926        }
1927
1928        let total = memories.len();
1929        memories.truncate(limit);
1930
1931        let results: Vec<Value> = memories
1932            .iter()
1933            .map(|m| {
1934                json!({
1935                    "id": m.metadata.id,
1936                    "content": &m.content,
1937                    "importance": m.metadata.importance,
1938                    "created_at": m.metadata.created_at.to_rfc3339(),
1939                    "accessed_at": m.metadata.accessed_at.to_rfc3339(),
1940                    "access_count": m.metadata.access_count,
1941                    "tags": &m.metadata.tags,
1942                })
1943            })
1944            .collect();
1945
1946        Ok(json!({
1947            "status": "success",
1948            "galaxy": galaxy_name(galaxy),
1949            "sort_by": sort_by,
1950            "order": order,
1951            "total": total,
1952            "returned": results.len(),
1953            "memories": results,
1954        }))
1955    }
1956    fn stats(&self) -> &ToolStats {
1957        &self.stats
1958    }
1959}
1960
1961/// `memory.filter` — filter memories by tags, date range, importance.
1962pub struct MemoryFilterTool {
1963    store: Arc<MemoryStore>,
1964    stats: ToolStats,
1965    effects: EffectRow,
1966}
1967
1968impl MemoryFilterTool {
1969    pub fn new(store: Arc<MemoryStore>) -> Self {
1970        Self {
1971            store,
1972            stats: ToolStats::default(),
1973            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1974        }
1975    }
1976}
1977
1978#[async_trait]
1979impl Tool for MemoryFilterTool {
1980    fn name(&self) -> &str {
1981        "memory.filter"
1982    }
1983    fn gana(&self) -> Gana {
1984        Gana::WinnowingBasket
1985    }
1986    fn effects(&self) -> &EffectRow {
1987        &self.effects
1988    }
1989    fn description(&self) -> &str {
1990        "Filter memories by tags, date range, importance thresholds, and a content query substring"
1991    }
1992    fn input_schema(&self) -> Value {
1993        super::common::schema(
1994            &json!({
1995                "galaxy": super::common::str_prop("Galaxy to filter (default codex)"),
1996                "tags": super::common::str_array_prop("Filter: memories with all of these tags"),
1997                "exclude_tags": super::common::str_array_prop("Filter: drop memories carrying any of these tags"),
1998                "min_importance": super::common::num_prop("Filter: minimum importance (0-1)"),
1999                "max_importance": super::common::num_prop("Filter: maximum importance (0-1)"),
2000                "query": super::common::str_prop("Filter: every whitespace-separated term must appear (case-insensitive) in the content or title"),
2001                "created_after": super::common::str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
2002                "created_before": super::common::str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
2003                "limit": super::common::int_prop("Maximum entries (default 50)"),
2004                "offset": super::common::int_prop("Skip this many matching entries before returning (default 0)"),
2005            }),
2006            &[],
2007        )
2008    }
2009    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2010        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2011        let tags: Vec<String> = args
2012            .get("tags")
2013            .and_then(|v| v.as_array())
2014            .map(|arr| {
2015                arr.iter()
2016                    .filter_map(|t| t.as_str().map(String::from))
2017                    .collect()
2018            })
2019            .unwrap_or_default();
2020        let exclude_tags: Vec<String> = args
2021            .get("exclude_tags")
2022            .and_then(|v| v.as_array())
2023            .map(|arr| {
2024                arr.iter()
2025                    .filter_map(|t| t.as_str().map(String::from))
2026                    .collect()
2027            })
2028            .unwrap_or_default();
2029        let min_importance = args
2030            .get("min_importance")
2031            .and_then(serde_json::Value::as_f64)
2032            .unwrap_or(0.0) as f32;
2033        let max_importance = args
2034            .get("max_importance")
2035            .and_then(serde_json::Value::as_f64)
2036            .unwrap_or(1.0) as f32;
2037        let limit = args
2038            .get("limit")
2039            .and_then(serde_json::Value::as_u64)
2040            .unwrap_or(50) as usize;
2041        let offset = args
2042            .get("offset")
2043            .and_then(serde_json::Value::as_u64)
2044            .unwrap_or(0) as usize;
2045        // Date range — promised by the description, previously ignored.
2046        // Bounds are inclusive RFC 3339 timestamps (e.g. "2026-08-01T00:00:00Z").
2047        let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
2048            match args.get(name).and_then(|v| v.as_str()) {
2049                Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
2050                    .map(|t| Some(t.with_timezone(&chrono::Utc)))
2051                    .map_err(|_| {
2052                        wm_core::CoreError::InvalidArgs(format!(
2053                            "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
2054                        ))
2055                    }),
2056                _ => Ok(None),
2057            }
2058        };
2059        let created_after = parse_bound("created_after")?;
2060        let created_before = parse_bound("created_before")?;
2061        // Content query — previously accepted-and-ignored (the arg was
2062        // silently dropped). Every term must appear case-insensitively in
2063        // the content or title; empty/absent means no content filtering.
2064        let query_terms: Vec<String> = args
2065            .get("query")
2066            .and_then(|v| v.as_str())
2067            .map(|q| q.split_whitespace().map(str::to_lowercase).collect())
2068            .unwrap_or_default();
2069
2070        let memories = self.store.scan(galaxy, 10_000)?;
2071
2072        let matched: Vec<&wm_memory::Memory> = memories
2073            .iter()
2074            .filter(|m| {
2075                // Private memories never appear in MCP responses.
2076                if !crate::expansion::common::mcp_visible(m) {
2077                    return false;
2078                }
2079                // Non-current validity hides while enforced (Slice B).
2080                if !crate::expansion::common::validity_visible(m) {
2081                    return false;
2082                }
2083                if m.metadata.importance < min_importance || m.metadata.importance > max_importance
2084                {
2085                    return false;
2086                }
2087                if !tags.is_empty() && !tags.iter().all(|t| m.metadata.tags.contains(t)) {
2088                    return false;
2089                }
2090                if exclude_tags
2091                    .iter()
2092                    .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
2093                {
2094                    return false;
2095                }
2096                if let Some(after) = created_after {
2097                    if m.metadata.created_at < after {
2098                        return false;
2099                    }
2100                }
2101                if let Some(before) = created_before {
2102                    if m.metadata.created_at > before {
2103                        return false;
2104                    }
2105                }
2106                if !query_terms.is_empty() {
2107                    let haystack = match &m.metadata.title {
2108                        Some(title) => format!("{}\n{}", m.content, title).to_lowercase(),
2109                        None => m.content.to_lowercase(),
2110                    };
2111                    if !query_terms.iter().all(|t| haystack.contains(t)) {
2112                        return false;
2113                    }
2114                }
2115                true
2116            })
2117            .collect();
2118        // Page AFTER filtering: offset/limit address the visible surface.
2119        let filtered: Vec<&&wm_memory::Memory> = matched.iter().skip(offset).take(limit).collect();
2120
2121        let total_scanned = memories.len();
2122        let results: Vec<Value> = filtered
2123            .iter()
2124            .map(|m| {
2125                json!({
2126                    "id": m.metadata.id,
2127                    "content": &m.content,
2128                    "importance": m.metadata.importance,
2129                    "tags": &m.metadata.tags,
2130                    "created_at": m.metadata.created_at.to_rfc3339(),
2131                })
2132            })
2133            .collect();
2134
2135        Ok(json!({
2136            "status": "success",
2137            "galaxy": galaxy_name(galaxy),
2138            "scanned": total_scanned,
2139            "matched": matched.len(),
2140            "offset": offset,
2141            "returned": results.len(),
2142            "filters": {
2143                "tags": tags,
2144                "exclude_tags": exclude_tags,
2145                "min_importance": min_importance,
2146                "max_importance": max_importance,
2147                "query_terms": query_terms,
2148                "created_after": created_after.map(|t| t.to_rfc3339()),
2149                "created_before": created_before.map(|t| t.to_rfc3339()),
2150            },
2151            "memories": results,
2152        }))
2153    }
2154    fn stats(&self) -> &ToolStats {
2155        &self.stats
2156    }
2157}
2158
2159/// `memory.deduplicate` — find and merge duplicate memories by content similarity.
2160pub struct MemoryDeduplicateTool {
2161    store: Arc<MemoryStore>,
2162    search: Option<Arc<SearchEngine>>,
2163    stats: ToolStats,
2164    effects: EffectRow,
2165}
2166
2167impl MemoryDeduplicateTool {
2168    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
2169        Self {
2170            store,
2171            search,
2172            stats: ToolStats::default(),
2173            effects: EffectRow {
2174                writes: super::common::memory_galaxy_writes(),
2175                reads: super::common::memory_galaxy_reads(),
2176                destructive: true,
2177                ..Default::default()
2178            },
2179        }
2180    }
2181}
2182
2183#[async_trait]
2184impl Tool for MemoryDeduplicateTool {
2185    fn name(&self) -> &str {
2186        "memory.deduplicate"
2187    }
2188    fn gana(&self) -> Gana {
2189        Gana::WinnowingBasket
2190    }
2191    fn effects(&self) -> &EffectRow {
2192        &self.effects
2193    }
2194    fn description(&self) -> &str {
2195        "Find and merge duplicate memories by content hash or similarity"
2196    }
2197    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2198        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2199        let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("hash");
2200        let dry_run = args
2201            .get("dry_run")
2202            .and_then(serde_json::Value::as_bool)
2203            .unwrap_or(true);
2204        let limit = args
2205            .get("limit")
2206            .and_then(serde_json::Value::as_u64)
2207            .unwrap_or(10_000) as usize;
2208
2209        let memories = self.store.scan(galaxy, limit)?;
2210
2211        match mode {
2212            "hash" => {
2213                let mut seen_hashes: HashMap<String, uuid::Uuid> = HashMap::new();
2214                let mut duplicates: Vec<Value> = Vec::new();
2215
2216                for mem in &memories {
2217                    let hash = &mem.metadata.content_hash;
2218                    if let Some(existing_id) = seen_hashes.get(hash) {
2219                        if *existing_id != mem.metadata.id {
2220                            duplicates.push(json!({
2221                                "id": mem.metadata.id,
2222                                "duplicate_of": existing_id,
2223                                "content_preview": mem.content.chars().take(100).collect::<String>(),
2224                                "importance": mem.metadata.importance,
2225                            }));
2226                            if !dry_run {
2227                                self.store.delete(galaxy, mem.metadata.id)?;
2228                                super::common::deindex(
2229                                    self.search.as_deref(),
2230                                    &mem.metadata.id.to_string(),
2231                                );
2232                            }
2233                        }
2234                    } else {
2235                        seen_hashes.insert(hash.clone(), mem.metadata.id);
2236                    }
2237                }
2238
2239                let removed = if dry_run { 0 } else { duplicates.len() };
2240
2241                Ok(json!({
2242                    "status": "success",
2243                    "galaxy": galaxy_name(galaxy),
2244                    "mode": mode,
2245                    "dry_run": dry_run,
2246                    "scanned": memories.len(),
2247                    "duplicates_found": duplicates.len(),
2248                    "removed": removed,
2249                    "duplicates": duplicates,
2250                }))
2251            }
2252            "content" => {
2253                let mut duplicates: Vec<Value> = Vec::new();
2254                let mut removed_count = 0u32;
2255
2256                for i in 0..memories.len() {
2257                    for j in (i + 1)..memories.len() {
2258                        if memories[i].content == memories[j].content {
2259                            duplicates.push(json!({
2260                                "id": memories[j].metadata.id,
2261                                "duplicate_of": memories[i].metadata.id,
2262                                "content_preview": memories[j].content.chars().take(100).collect::<String>(),
2263                            }));
2264                            if !dry_run {
2265                                self.store.delete(galaxy, memories[j].metadata.id)?;
2266                                super::common::deindex(
2267                                    self.search.as_deref(),
2268                                    &memories[j].metadata.id.to_string(),
2269                                );
2270                                removed_count += 1;
2271                            }
2272                            break;
2273                        }
2274                    }
2275                }
2276
2277                Ok(json!({
2278                    "status": "success",
2279                    "galaxy": galaxy_name(galaxy),
2280                    "mode": mode,
2281                    "dry_run": dry_run,
2282                    "scanned": memories.len(),
2283                    "duplicates_found": duplicates.len(),
2284                    "removed": removed_count,
2285                    "duplicates": duplicates,
2286                }))
2287            }
2288            _ => Err(wm_core::CoreError::InvalidArgs(format!(
2289                "Unknown mode: '{mode}'. Use 'hash' or 'content'"
2290            ))),
2291        }
2292    }
2293    fn stats(&self) -> &ToolStats {
2294        &self.stats
2295    }
2296}
2297
2298/// `memory.export` — export memories in JSON, CSV, or Markdown format.
2299pub struct MemoryExportTool {
2300    store: Arc<MemoryStore>,
2301    stats: ToolStats,
2302    effects: EffectRow,
2303}
2304
2305impl MemoryExportTool {
2306    pub fn new(store: Arc<MemoryStore>) -> Self {
2307        Self {
2308            store,
2309            stats: ToolStats::default(),
2310            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
2311        }
2312    }
2313}
2314
2315#[async_trait]
2316impl Tool for MemoryExportTool {
2317    fn name(&self) -> &str {
2318        "memory.export"
2319    }
2320    fn gana(&self) -> Gana {
2321        Gana::WinnowingBasket
2322    }
2323    fn effects(&self) -> &EffectRow {
2324        &self.effects
2325    }
2326    fn description(&self) -> &str {
2327        "Export memories in JSON, CSV, or Markdown format"
2328    }
2329    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2330        let galaxy = parse_galaxy_or(args.get("galaxy").and_then(|v| v.as_str()), Galaxy::Codex)?;
2331        let format = args
2332            .get("format")
2333            .and_then(|v| v.as_str())
2334            .unwrap_or("json");
2335        let limit = args
2336            .get("limit")
2337            .and_then(serde_json::Value::as_u64)
2338            .unwrap_or(1000) as usize;
2339
2340        let memories = self.store.scan(galaxy, limit)?;
2341
2342        let exported = match format {
2343            "json" => {
2344                let entries: Vec<Value> = memories
2345                    .iter()
2346                    .map(|m| {
2347                        json!({
2348                            "id": m.metadata.id,
2349                            "content": &m.content,
2350                            "tags": &m.metadata.tags,
2351                            "importance": m.metadata.importance,
2352                            "created_at": m.metadata.created_at.to_rfc3339(),
2353                            "access_count": m.metadata.access_count,
2354                        })
2355                    })
2356                    .collect();
2357                serde_json::to_string_pretty(&entries).unwrap_or_default()
2358            }
2359            "csv" => {
2360                let mut csv = String::from("id,content,tags,importance,created_at,access_count\n");
2361                for m in &memories {
2362                    let tags = m.metadata.tags.join(";");
2363                    let content = m.content.replace('\n', " ").replace('"', "'");
2364                    let _ = writeln!(
2365                        csv,
2366                        "{},{},{},{:.3},{},{}",
2367                        m.metadata.id,
2368                        content,
2369                        tags,
2370                        m.metadata.importance,
2371                        m.metadata.created_at.to_rfc3339(),
2372                        m.metadata.access_count,
2373                    );
2374                }
2375                csv
2376            }
2377            "markdown" => {
2378                let mut md = format!("# Memory Export: {}\n\n", galaxy_name(galaxy));
2379                let _ = write!(md, "Total memories: {}\n\n", memories.len());
2380                for m in &memories {
2381                    let _ = write!(
2382                        md,
2383                        "## {}\n\n- **Importance**: {:.2}\n- **Tags**: {}\n- **Created**: {}\n- **Access Count**: {}\n\n{}\n\n---\n\n",
2384                        m.metadata.id,
2385                        m.metadata.importance,
2386                        m.metadata.tags.join(", "),
2387                        m.metadata.created_at.to_rfc3339(),
2388                        m.metadata.access_count,
2389                        m.content,
2390                    );
2391                }
2392                md
2393            }
2394            _ => {
2395                return Err(wm_core::CoreError::InvalidArgs(format!(
2396                    "Unknown format: '{format}'. Use json, csv, or markdown"
2397                )));
2398            }
2399        };
2400
2401        Ok(json!({
2402            "status": "success",
2403            "galaxy": galaxy_name(galaxy),
2404            "format": format,
2405            "count": memories.len(),
2406            "export": exported,
2407        }))
2408    }
2409    fn stats(&self) -> &ToolStats {
2410        &self.stats
2411    }
2412}
2413
2414#[cfg(test)]
2415mod tests {
2416    use super::*;
2417    use wm_core::{EpisodicKind, EpisodicRecord, Galaxy, Provenance, ProvenanceSource};
2418    use wm_memory::{Association, AssociationStore, LinkType, Memory, MemoryStore};
2419
2420    fn test_store() -> Arc<MemoryStore> {
2421        let dir = tempfile::tempdir().unwrap();
2422        Arc::new(MemoryStore::open_default(dir.path()).unwrap())
2423    }
2424
2425    fn populate_memories(store: &Arc<MemoryStore>, galaxy: Galaxy) {
2426        let mut m1 = Memory::new(galaxy, "First memory about rust".into());
2427        m1.metadata.importance = 0.9;
2428        m1.metadata.tags = vec!["rust".into(), "programming".into()];
2429        let _ = store.put(galaxy, &m1);
2430
2431        let mut m2 = Memory::new(galaxy, "Second memory about python".into());
2432        m2.metadata.importance = 0.5;
2433        m2.metadata.tags = vec!["python".into()];
2434        let _ = store.put(galaxy, &m2);
2435
2436        let mut m3 = Memory::new(galaxy, "Third memory about rust".into());
2437        m3.metadata.importance = 0.3;
2438        m3.metadata.tags = vec!["rust".into(), "tutorial".into()];
2439        let _ = store.put(galaxy, &m3);
2440    }
2441
2442    #[tokio::test]
2443    async fn episodic_search_filters_private_records() {
2444        let store = test_store();
2445        let public = EpisodicRecord::new(
2446            None,
2447            1,
2448            EpisodicKind::Observation,
2449            "public retrieval evidence",
2450            Provenance::new(ProvenanceSource::User),
2451        );
2452        let private = EpisodicRecord::new(
2453            None,
2454            2,
2455            EpisodicKind::Observation,
2456            "private retrieval evidence",
2457            Provenance::new(ProvenanceSource::User),
2458        )
2459        .with_visibility(true, false);
2460        store.episodic().append(&public).unwrap();
2461        store.episodic().append(&private).unwrap();
2462
2463        let tool = MemoryEpisodicSearchTool::new(store);
2464        let mut ctx = Context::default();
2465        let result = tool
2466            .call(
2467                &mut ctx,
2468                json!({"query": "retrieval evidence", "limit": 10}),
2469            )
2470            .await
2471            .unwrap();
2472        assert_eq!(result["count"], 1);
2473        assert_eq!(result["results"][0]["id"], json!(public.id));
2474    }
2475
2476    /// Mirror a v5 memory into the episodic lane exactly like the write
2477    /// path does (capture_explicit_memory): record id = memory id.
2478    fn mirror_memory(
2479        store: &Arc<MemoryStore>,
2480        mem: &Memory,
2481        session: Option<uuid::Uuid>,
2482        sequence: u64,
2483    ) {
2484        use wm_core::EpisodicCapturePolicy;
2485        let record = EpisodicRecord::new(
2486            session,
2487            sequence,
2488            EpisodicKind::Observation,
2489            mem.content.clone(),
2490            Provenance::new(ProvenanceSource::User),
2491        )
2492        .with_id(mem.metadata.id)
2493        .with_visibility(mem.metadata.is_private, mem.metadata.model_exclude);
2494        store
2495            .episodic()
2496            .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
2497            .unwrap();
2498    }
2499
2500    fn default_search_tool(
2501        store: Arc<MemoryStore>,
2502        search: Option<Arc<SearchEngine>>,
2503    ) -> MemoryHybridRecallTool {
2504        MemoryHybridRecallTool::as_search(store, search, None)
2505    }
2506
2507    #[tokio::test]
2508    async fn default_route_prefers_episodic_and_discloses_mode() {
2509        // V8 ship list #1: with no real embedder, memory.search must route
2510        // through the episodic deterministic machinery by default and
2511        // disclose `recall_mode: episodic` + per-result `source`.
2512        let (_dir, store, search) = hybrid_fixture();
2513        let needle = Memory::new(
2514            Galaxy::Codex,
2515            "Kotlin coroutine budget meeting notes".into(),
2516        );
2517        let needle_id = needle.metadata.id;
2518        let other = Memory::new(Galaxy::Codex, "Grocery list eggs and flour".into());
2519        store.put(Galaxy::Codex, &needle).unwrap();
2520        store.put(Galaxy::Codex, &other).unwrap();
2521        mirror_memory(&store, &needle, None, 1);
2522        mirror_memory(&store, &other, None, 2);
2523
2524        let tool = default_search_tool(store.clone(), Some(search));
2525        let mut ctx = Context::default();
2526        let v = tool
2527            .call(
2528                &mut ctx,
2529                json!({"query": "kotlin coroutine budget", "limit": 10}),
2530            )
2531            .await
2532            .unwrap();
2533        assert_eq!(v["recall_mode"], "episodic");
2534        assert_eq!(v["results"][0]["source"], "episodic");
2535        assert_eq!(v["results"][0]["id"], json!(needle_id.to_string()));
2536        assert!(v["results"][0]["score"].as_f64().unwrap() > 0.0);
2537    }
2538
2539    #[tokio::test]
2540    async fn default_route_matches_the_episodic_machinery_ranking() {
2541        // The default route must BE the episodic machinery, not a lookalike:
2542        // same corpus, same query, first result identical to
2543        // memory.episodic_search's top hit.
2544        let (_dir, store, search) = hybrid_fixture();
2545        let contents = [
2546            "Deployed the telemetry agent on Tuesday",
2547            "Cancun trip booked for the twelfth",
2548            "Telemetry agent rollout postponed to Friday",
2549            "Deadline for the quarterly report moved",
2550        ];
2551        let mut memories: Vec<(uuid::Uuid, &str)> = Vec::new();
2552        for (i, content) in contents.iter().enumerate() {
2553            let mem = Memory::new(Galaxy::Codex, (*content).to_string());
2554            memories.push((mem.metadata.id, content));
2555            store.put(Galaxy::Codex, &mem).unwrap();
2556            mirror_memory(&store, &mem, None, i as u64 + 1);
2557        }
2558        let query = "when was the telemetry agent deployed";
2559
2560        let default_tool = default_search_tool(store.clone(), Some(search.clone()));
2561        let mut ctx = Context::default();
2562        let default_v = default_tool
2563            .call(&mut ctx, json!({"query": query, "limit": 10}))
2564            .await
2565            .unwrap();
2566        let episodic_tool = MemoryEpisodicSearchTool::new(store);
2567        let episodic_v = episodic_tool
2568            .call(&mut ctx, json!({"query": query, "limit": 10}))
2569            .await
2570            .unwrap();
2571        assert_eq!(
2572            default_v["results"][0]["id"], episodic_v["results"][0]["id"],
2573            "default route must rank exactly like the episodic machinery"
2574        );
2575        let top = memories
2576            .iter()
2577            .find(|(id, _)| id.to_string() == default_v["results"][0]["id"])
2578            .map(|(_, c)| *c)
2579            .unwrap();
2580        assert_eq!(top, "Deployed the telemetry agent on Tuesday");
2581    }
2582
2583    #[tokio::test]
2584    async fn default_route_falls_back_to_fts_when_episodic_yields_nothing() {
2585        // Legacy store shape: memories indexed but the episodic lane never
2586        // populated. The default route must disclose `fts` honestly.
2587        let (_dir, store, search) = hybrid_fixture();
2588        let mem = Memory::new(Galaxy::Codex, "Zebra quotas revised upward".into());
2589        let id = mem.metadata.id;
2590        store.put(Galaxy::Codex, &mem).unwrap();
2591        {
2592            let mut writer = search.writer().unwrap();
2593            search
2594                .add_document(
2595                    &mut writer,
2596                    &id.to_string(),
2597                    "codex",
2598                    "Zebra quotas revised upward",
2599                    &mem.metadata.tags,
2600                    mem.metadata.created_at.timestamp(),
2601                )
2602                .unwrap();
2603            search.commit(&mut writer).unwrap();
2604        }
2605
2606        let tool = default_search_tool(store, Some(search));
2607        let mut ctx = Context::default();
2608        let v = tool
2609            .call(&mut ctx, json!({"query": "zebra quotas", "limit": 10}))
2610            .await
2611            .unwrap();
2612        assert_eq!(v["recall_mode"], "fts");
2613        assert_eq!(v["results"][0]["source"], "fts");
2614        assert_eq!(v["results"][0]["id"], json!(id.to_string()));
2615    }
2616
2617    #[tokio::test]
2618    async fn episodic_default_route_respects_galaxy_filter() {
2619        let (_dir, store, search) = hybrid_fixture();
2620        let in_galaxy = Memory::new(Galaxy::Codex, "Marble fountain restoration plan".into());
2621        let other_galaxy =
2622            Memory::new(Galaxy::Sessions, "Marble fountain restoration notes".into());
2623        store.put(Galaxy::Codex, &in_galaxy).unwrap();
2624        store.put(Galaxy::Sessions, &other_galaxy).unwrap();
2625        mirror_memory(&store, &in_galaxy, None, 1);
2626        mirror_memory(&store, &other_galaxy, None, 2);
2627
2628        let tool = default_search_tool(store, Some(search));
2629        let mut ctx = Context::default();
2630        let v = tool
2631            .call(
2632                &mut ctx,
2633                json!({"query": "marble fountain", "galaxy": "sessions", "limit": 10}),
2634            )
2635            .await
2636            .unwrap();
2637        assert_eq!(v["recall_mode"], "episodic");
2638        for r in v["results"].as_array().unwrap() {
2639            assert_eq!(r["galaxy"], "sessions", "galaxy filter must hold");
2640        }
2641        assert_eq!(
2642            v["results"][0]["id"],
2643            json!(other_galaxy.metadata.id.to_string())
2644        );
2645    }
2646
2647    #[tokio::test]
2648    async fn episodic_default_route_filters_private_and_stale() {
2649        let (_dir, store, search) = hybrid_fixture();
2650        let public = Memory::new(
2651            Galaxy::Codex,
2652            "Lighthouse maintenance schedule confirmed".into(),
2653        );
2654        let mut private = Memory::new(Galaxy::Codex, "Lighthouse access code renewal".into());
2655        private.metadata.is_private = true;
2656        let stale = Memory::new(Galaxy::Codex, "Lighthouse inspection legacy draft".into());
2657        let stale_id = stale.metadata.id;
2658        store.put(Galaxy::Codex, &public).unwrap();
2659        store.put(Galaxy::Codex, &private).unwrap();
2660        store.put(Galaxy::Codex, &stale).unwrap();
2661        mirror_memory(&store, &public, None, 1);
2662        mirror_memory(&store, &private, None, 2);
2663        mirror_memory(&store, &stale, None, 3);
2664        // The stale record's v5 memory is gone — the mirror survived, the
2665        // source of truth did not.
2666        store.delete(Galaxy::Codex, stale_id).unwrap();
2667
2668        let tool = default_search_tool(store, Some(search));
2669        let mut ctx = Context::default();
2670        let v = tool
2671            .call(&mut ctx, json!({"query": "lighthouse", "limit": 10}))
2672            .await
2673            .unwrap();
2674        assert_eq!(v["recall_mode"], "episodic");
2675        let ids: Vec<&str> = v["results"]
2676            .as_array()
2677            .unwrap()
2678            .iter()
2679            .filter_map(|r| r["id"].as_str())
2680            .collect();
2681        assert!(
2682            !ids.contains(&private.metadata.id.to_string().as_str()),
2683            "private memories must never surface on the default route"
2684        );
2685        assert!(
2686            !ids.contains(&stale_id.to_string().as_str()),
2687            "episodic records without a live v5 memory must be skipped"
2688        );
2689        assert!(!ids.is_empty(), "the public hit must still surface");
2690    }
2691
2692    #[tokio::test]
2693    async fn memory_sort_by_importance_desc() {
2694        let store = test_store();
2695        populate_memories(&store, Galaxy::Codex);
2696        let tool = MemorySortTool::new(store);
2697        let mut ctx = Context::default();
2698        let v = tool
2699            .call(&mut ctx, json!({"sort_by": "importance", "order": "desc"}))
2700            .await
2701            .unwrap();
2702        assert_eq!(v["status"], "success");
2703        assert_eq!(v["returned"], 3);
2704        let mems = v["memories"].as_array().unwrap();
2705        assert!(mems[0]["importance"].as_f64().unwrap() >= mems[1]["importance"].as_f64().unwrap());
2706    }
2707
2708    #[tokio::test]
2709    async fn memory_update_cannot_mutate_tier() {
2710        // S5 phase 2: tier moves are dream-cycle-ONLY. The update tool
2711        // whitelists its fields — a `tier` argument in the payload must be
2712        // ignored, not applied.
2713        let store = test_store();
2714        let mem = Memory::new(Galaxy::Codex, "tier is not client-settable".into());
2715        let id = mem.metadata.id;
2716        store.put(Galaxy::Codex, &mem).unwrap();
2717
2718        let tool = MemoryUpdateTool::new(store.clone(), None);
2719        let mut ctx = Context::default();
2720        let v = tool
2721            .call(
2722                &mut ctx,
2723                json!({"galaxy": "codex", "id": id.to_string(), "tier": "archival", "tags": ["x"]}),
2724            )
2725            .await
2726            .unwrap();
2727        assert_eq!(v["status"], "success");
2728
2729        let after = store.get(Galaxy::Codex, id).unwrap().unwrap();
2730        assert_eq!(
2731            after.metadata.tier,
2732            wm_memory::Tier::Working,
2733            "memory.update must never move the tier"
2734        );
2735        assert_eq!(
2736            after.metadata.tags,
2737            vec!["x".to_string()],
2738            "whitelisted fields still apply"
2739        );
2740    }
2741
2742    #[tokio::test]
2743    async fn empty_search_hints_at_populated_galaxies() {
2744        // Content lives in `sessions`, not `codex` (the vault-store shape).
2745        // The galaxy-unfiltered default (no `galaxy` arg) must FIND it and
2746        // label the result with its galaxy — that is the post-cutover fix
2747        // (2026-08-29): hits used to be resolved against `codex` only.
2748        let (_dir, store, search) = hybrid_fixture();
2749        index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
2750        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
2751        let mut ctx = Context::default();
2752        let v = tool
2753            .call(&mut ctx, json!({"query": "gate plan"}))
2754            .await
2755            .unwrap();
2756        assert_eq!(
2757            v["count"], 1,
2758            "unfiltered search must find cross-galaxy content: {v}"
2759        );
2760        assert_eq!(v["galaxy"], "all");
2761        assert_eq!(v["results"][0]["galaxy"], "sessions");
2762        assert!(v["hint"].is_null());
2763
2764        // An explicit galaxy still filters at the index and hints on a miss.
2765        let v2 = tool
2766            .call(
2767                &mut ctx,
2768                json!({"query": "zzz-no-match", "galaxy": "sessions"}),
2769            )
2770            .await
2771            .unwrap();
2772        assert_eq!(v2["count"], 0);
2773        let hint2 = v2["hint"].as_str().unwrap();
2774        assert!(hint2.contains("no matches for this query"), "{hint2}");
2775
2776        // A no-match query WITHOUT a galaxy filter searches everywhere and
2777        // reports the overall corpus shape instead of a per-galaxy view.
2778        let v3 = tool
2779            .call(&mut ctx, json!({"query": "zzz-no-match"}))
2780            .await
2781            .unwrap();
2782        assert_eq!(v3["count"], 0);
2783        let hint3 = v3["hint"].as_str().expect("hint present on empty result");
2784        assert!(hint3.contains("across all memory galaxies"), "{hint3}");
2785    }
2786
2787    #[tokio::test]
2788    async fn unfiltered_search_labels_hits_from_every_galaxy() {
2789        // Content spread across three galaxies; the default search (no
2790        // `galaxy` arg) must surface all of them, each labeled. This is the
2791        // federated-recall regression: every backing's rich content lived
2792        // outside `codex` and the old resolution path returned silent zeros.
2793        let (_dir, store, search) = hybrid_fixture();
2794        index_memory(
2795            &store,
2796            &search,
2797            Galaxy::Sessions,
2798            "lineage ledger phase four",
2799        );
2800        index_memory(
2801            &store,
2802            &search,
2803            Galaxy::Codex,
2804            "lineage ledger codex mirror note",
2805        );
2806        index_memory(&store, &search, Galaxy::Dreams, "lineage ledger dream echo");
2807        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search), None);
2808        let mut ctx = Context::default();
2809        let v = tool
2810            .call(&mut ctx, json!({"query": "lineage ledger", "limit": 10}))
2811            .await
2812            .unwrap();
2813        assert_eq!(v["count"], 3, "got: {v}");
2814        let galaxies: Vec<&str> = v["results"]
2815            .as_array()
2816            .unwrap()
2817            .iter()
2818            .map(|r| r["galaxy"].as_str().unwrap())
2819            .collect();
2820        assert!(galaxies.contains(&"sessions"), "got: {galaxies:?}");
2821        assert!(galaxies.contains(&"codex"), "got: {galaxies:?}");
2822        assert!(galaxies.contains(&"dreams"), "got: {galaxies:?}");
2823
2824        // Explicit galaxy filters to exactly that galaxy.
2825        let v2 = tool
2826            .call(
2827                &mut ctx,
2828                json!({"query": "lineage ledger", "galaxy": "dreams"}),
2829            )
2830            .await
2831            .unwrap();
2832        assert_eq!(v2["count"], 1, "got: {v2}");
2833        assert_eq!(v2["results"][0]["galaxy"], "dreams");
2834    }
2835
2836    #[tokio::test]
2837    async fn successful_search_carries_no_hint() {
2838        let (_dir, store, search) = hybrid_fixture();
2839        index_memory(&store, &search, Galaxy::Sessions, "gate plan decision");
2840        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
2841        let mut ctx = Context::default();
2842        let v = tool
2843            .call(
2844                &mut ctx,
2845                json!({"query": "gate plan", "galaxy": "sessions"}),
2846            )
2847            .await
2848            .unwrap();
2849        assert_eq!(v["count"], 1);
2850        assert!(v["hint"].is_null());
2851    }
2852
2853    #[tokio::test]
2854    async fn associative_expansion_surfaces_linked_memory() {
2855        // The core spreading-activation contract: a direct hit on memory A
2856        // activates its one-hop neighbor B even though B shares no query
2857        // terms, and B is marked source=association with its link metadata.
2858        let dir = tempfile::tempdir().unwrap();
2859        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
2860        let tantivy_dir = dir.path().join("tantivy");
2861        std::fs::create_dir_all(&tantivy_dir).unwrap();
2862        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
2863        index_memory(
2864            &store,
2865            &search,
2866            Galaxy::Codex,
2867            "gate plan for the v7 alpha release",
2868        );
2869        let mut linked = Memory::new(
2870            Galaxy::Codex,
2871            "backup automation runs nightly at 03:30".into(),
2872        );
2873        linked.metadata.importance = 0.7;
2874        let linked_id = linked.metadata.id;
2875        store.put(Galaxy::Codex, &linked).unwrap();
2876        search
2877            .writer()
2878            .and_then(|mut w| {
2879                search.add_document(
2880                    &mut w,
2881                    &linked_id.to_string(),
2882                    "codex",
2883                    &linked.content,
2884                    &linked.metadata.tags,
2885                    linked.metadata.created_at.timestamp(),
2886                )?;
2887                search.commit(&mut w)
2888            })
2889            .unwrap();
2890
2891        // The association must NOT share vocabulary with the query.
2892        let assoc = Association::new(
2893            find_id(&store, "gate plan"),
2894            linked_id,
2895            LinkType::Extends,
2896            0.8,
2897        );
2898        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
2899        associations.put(store.env(), &assoc).unwrap();
2900
2901        let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
2902            .with_associations(Some(associations));
2903        let mut ctx = Context::default();
2904        let v = tool
2905            .call(&mut ctx, json!({"query": "gate plan alpha release"}))
2906            .await
2907            .unwrap();
2908        assert_eq!(v["count"], 2, "direct hit + associated memory: {v}");
2909        let assoc_hit = v["results"]
2910            .as_array()
2911            .unwrap()
2912            .iter()
2913            .find(|r| r["source"] == "association")
2914            .expect("association-sourced result present");
2915        assert_eq!(assoc_hit["id"], json!(linked_id.to_string()));
2916        assert_eq!(assoc_hit["link_type"], "extends");
2917        assert!(assoc_hit["via"].is_string());
2918        assert!(assoc_hit["weight"].as_f64().unwrap() > 0.7);
2919    }
2920
2921    fn find_id(store: &MemoryStore, needle: &str) -> uuid::Uuid {
2922        store
2923            .scan(Galaxy::Codex, 100)
2924            .unwrap()
2925            .into_iter()
2926            .find(|m| m.content.contains(needle))
2927            .map(|m| m.metadata.id)
2928            .unwrap()
2929    }
2930
2931    #[tokio::test]
2932    async fn associative_expansion_skips_private_and_dedupes() {
2933        let dir = tempfile::tempdir().unwrap();
2934        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
2935        let mut a = Memory::new(Galaxy::Codex, "quarterly revenue planning notes".into());
2936        a.metadata.importance = 0.8;
2937        let a_id = a.metadata.id;
2938        store.put(Galaxy::Codex, &a).unwrap();
2939        // Private neighbor: must never surface through expansion.
2940        let mut private = Memory::new(Galaxy::Codex, "private salary bands".into());
2941        private.metadata.is_private = true;
2942        private.metadata.importance = 0.8;
2943        store.put(Galaxy::Codex, &private).unwrap();
2944        // Public neighbor linked twice (both directions) — must appear once.
2945        let mut b = Memory::new(Galaxy::Codex, "hiring plan for next quarter".into());
2946        b.metadata.importance = 0.7;
2947        let b_id = b.metadata.id;
2948        store.put(Galaxy::Codex, &b).unwrap();
2949
2950        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
2951        associations
2952            .put(
2953                store.env(),
2954                &Association::new(a_id, private.metadata.id, LinkType::Related, 0.9),
2955            )
2956            .unwrap();
2957        associations
2958            .put(
2959                store.env(),
2960                &Association::new(a_id, b_id, LinkType::Related, 0.9),
2961            )
2962            .unwrap();
2963        associations
2964            .put(
2965                store.env(),
2966                &Association::new(b_id, a_id, LinkType::Related, 0.9),
2967            )
2968            .unwrap();
2969
2970        let tool = MemoryHybridRecallTool::as_search(store.clone(), None, None)
2971            .with_associations(Some(associations.clone()));
2972        let mut ctx = Context::default();
2973        // No SearchEngine: scan-free importance path still seeds anchors? No —
2974        // with no query there is no seed; use a query but no FTS: results come
2975        // from Phase 1 only when search is present. So attach a search engine.
2976        let _ = &tool;
2977        let tantivy_dir = dir.path().join("tantivy");
2978        std::fs::create_dir_all(&tantivy_dir).unwrap();
2979        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
2980        for (content, id) in [
2981            ("quarterly revenue planning notes", a_id),
2982            ("private salary bands", private.metadata.id),
2983            ("hiring plan for next quarter", b_id),
2984        ] {
2985            search
2986                .writer()
2987                .and_then(|mut w| {
2988                    search.add_document(&mut w, &id.to_string(), "codex", content, &[], 0)?;
2989                    search.commit(&mut w)
2990                })
2991                .unwrap();
2992        }
2993        let tool = MemoryHybridRecallTool::as_search(store.clone(), Some(search), None)
2994            .with_associations(Some(associations));
2995        let v = tool
2996            .call(&mut ctx, json!({"query": "quarterly revenue planning"}))
2997            .await
2998            .unwrap();
2999        let ids: Vec<&str> = v["results"]
3000            .as_array()
3001            .unwrap()
3002            .iter()
3003            .filter_map(|r| r["id"].as_str())
3004            .collect();
3005        assert!(
3006            !ids.iter().any(|id| *id == private.metadata.id.to_string()),
3007            "private memory must not surface via association: {ids:?}"
3008        );
3009        assert_eq!(
3010            ids.iter().filter(|id| **id == b_id.to_string()).count(),
3011            1,
3012            "neighbor linked both directions appears exactly once: {ids:?}"
3013        );
3014    }
3015
3016    #[tokio::test]
3017    async fn memory_update_content_recomputes_hash() {
3018        let store = test_store();
3019        let mem = Memory::new(Galaxy::Codex, "original text".into());
3020        store.put(Galaxy::Codex, &mem).unwrap();
3021        let id = mem.metadata.id;
3022        let original_hash = mem.metadata.content_hash.clone();
3023
3024        let tool = MemoryUpdateTool::new(store.clone(), None);
3025        let v = tool
3026            .call(
3027                &mut Context::default(),
3028                json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3029            )
3030            .await
3031            .unwrap();
3032        assert_eq!(v["status"], "success");
3033
3034        // Regression: content updates used to keep the old content hash,
3035        // leaving dedup and hash lookups pointing at stale content.
3036        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3037        assert_eq!(stored.content, "changed text");
3038        assert_eq!(
3039            stored.metadata.content_hash,
3040            wm_memory::content_hash("changed text")
3041        );
3042        assert_ne!(stored.metadata.content_hash, original_hash);
3043    }
3044
3045    #[tokio::test]
3046    async fn memory_update_discloses_hash_timeline() {
3047        // V8 S11a: every update response carries the (new) content_hash so
3048        // the write-audit journal records a hash timeline per memory; a
3049        // content-changing update additionally carries prev_content_hash.
3050        let store = test_store();
3051        let mem = Memory::new(Galaxy::Codex, "original text".into());
3052        store.put(Galaxy::Codex, &mem).unwrap();
3053        let id = mem.metadata.id;
3054        let original_hash = mem.metadata.content_hash.clone();
3055        let tool = MemoryUpdateTool::new(store.clone(), None);
3056        let mut ctx = Context::default();
3057
3058        let v = tool
3059            .call(
3060                &mut ctx,
3061                json!({"galaxy": "codex", "id": id.to_string(), "content": "changed text"}),
3062            )
3063            .await
3064            .unwrap();
3065        assert_eq!(
3066            v["content_hash"],
3067            json!(wm_memory::content_hash("changed text"))
3068        );
3069        assert_eq!(v["prev_content_hash"], json!(original_hash));
3070
3071        // A metadata-only update discloses the current hash and no prev.
3072        let v = tool
3073            .call(
3074                &mut ctx,
3075                json!({"galaxy": "codex", "id": id.to_string(), "tags": ["amended"]}),
3076            )
3077            .await
3078            .unwrap();
3079        assert_eq!(
3080            v["content_hash"],
3081            json!(wm_memory::content_hash("changed text"))
3082        );
3083        assert!(v.get("prev_content_hash").is_none());
3084    }
3085
3086    #[tokio::test]
3087    async fn memory_update_appends_revision_chain() {
3088        // V8 S11c: content changes append hash-linked revision entries;
3089        // metadata-only edits do not; the actor rides in from the context.
3090        let store = test_store();
3091        let mem = Memory::new(Galaxy::Codex, "original text".into());
3092        store.put(Galaxy::Codex, &mem).unwrap();
3093        let id = mem.metadata.id;
3094        let tool = MemoryUpdateTool::new(store.clone(), None);
3095        let mut ctx = Context {
3096            user_id: Some("agent-b".to_string()),
3097            session_id: Some(uuid::Uuid::nil()),
3098            compartment: Some("production".to_string()),
3099            ..Default::default()
3100        };
3101        let v = tool
3102            .call(
3103                &mut ctx,
3104                json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3105            )
3106            .await
3107            .unwrap();
3108        assert_eq!(v["revision"]["seq"], 0);
3109        assert_eq!(
3110            v["revision"]["old_hash"],
3111            json!(wm_memory::content_hash("original text"))
3112        );
3113        assert_eq!(
3114            v["revision"]["new_hash"],
3115            json!(wm_memory::content_hash("second text"))
3116        );
3117
3118        let v = tool
3119            .call(
3120                &mut ctx,
3121                json!({"galaxy": "codex", "id": id.to_string(), "content": "third text"}),
3122            )
3123            .await
3124            .unwrap();
3125        assert_eq!(v["revision"]["seq"], 1);
3126
3127        let revisions = store.revisions(Galaxy::Codex, id).unwrap();
3128        assert_eq!(revisions.len(), 2);
3129        assert_eq!(revisions[1].old_hash, revisions[0].new_hash, "chain links");
3130        assert_eq!(revisions[0].actor_user.as_deref(), Some("agent-b"));
3131        assert_eq!(
3132            revisions[0].actor_compartment.as_deref(),
3133            Some("production")
3134        );
3135        assert_eq!(
3136            revisions[0].actor_session.as_deref(),
3137            Some(uuid::Uuid::nil().to_string().as_str())
3138        );
3139
3140        let stored = store.get(Galaxy::Codex, id).unwrap().unwrap();
3141        assert_eq!(stored.metadata.revision_count, 2);
3142
3143        // The honest chain verifies clean against the live content hash.
3144        let report = store
3145            .verify_revision_chain(Galaxy::Codex, id, &stored.metadata.content_hash)
3146            .unwrap();
3147        assert!(report.valid, "{:?}", report.breaks);
3148        assert!(report.matches_head);
3149    }
3150
3151    #[tokio::test]
3152    async fn memory_update_out_of_band_edit_breaks_chain() {
3153        // Content changed WITHOUT the update tool (the write path the
3154        // journal sees but cannot describe) must break the head match.
3155        let store = test_store();
3156        let mem = Memory::new(Galaxy::Codex, "original text".into());
3157        store.put(Galaxy::Codex, &mem).unwrap();
3158        let id = mem.metadata.id;
3159        let tool = MemoryUpdateTool::new(store.clone(), None);
3160        tool.call(
3161            &mut Context::default(),
3162            json!({"galaxy": "codex", "id": id.to_string(), "content": "second text"}),
3163        )
3164        .await
3165        .unwrap();
3166
3167        // Out-of-band rewrite: hash moved, no revision appended.
3168        let mut row = store.get(Galaxy::Codex, id).unwrap().unwrap();
3169        row.content = "smuggled text".to_string();
3170        row.metadata.content_hash = wm_memory::content_hash("smuggled text");
3171        store.put(Galaxy::Codex, &row).unwrap();
3172
3173        let report = store
3174            .verify_revision_chain(Galaxy::Codex, id, &row.metadata.content_hash)
3175            .unwrap();
3176        assert!(!report.valid);
3177        assert!(!report.matches_head);
3178        assert!(report.breaks.iter().any(|b| b.contains("head mismatch")));
3179    }
3180
3181    #[tokio::test]
3182    async fn memory_revisions_tool_list_and_verify() {
3183        let store = test_store();
3184        let mem = Memory::new(Galaxy::Codex, "v1".into());
3185        store.put(Galaxy::Codex, &mem).unwrap();
3186        let id = mem.metadata.id;
3187        let update = MemoryUpdateTool::new(store.clone(), None);
3188        update
3189            .call(
3190                &mut Context::default(),
3191                json!({"galaxy": "codex", "id": id.to_string(), "content": "v2"}),
3192            )
3193            .await
3194            .unwrap();
3195
3196        let tool = MemoryRevisionsTool::new(store.clone());
3197        let v = tool
3198            .call(&mut Context::default(), json!({"id": id.to_string()}))
3199            .await
3200            .unwrap();
3201        assert_eq!(v["action"], "list");
3202        assert_eq!(v["count"], 1);
3203
3204        let v = tool
3205            .call(
3206                &mut Context::default(),
3207                json!({"id": id.to_string(), "action": "verify"}),
3208            )
3209            .await
3210            .unwrap();
3211        assert_eq!(v["valid"], true);
3212        assert_eq!(v["entries"], 1);
3213
3214        // An injected splice is detectable: entry 1 claims an old_hash the
3215        // chain never produced.
3216        store
3217            .record_revision(
3218                Galaxy::Codex,
3219                id,
3220                "forged_old_hash",
3221                &wm_memory::content_hash("v2"),
3222                wm_memory::RevisionActor::default(),
3223            )
3224            .unwrap();
3225        let v = tool
3226            .call(
3227                &mut Context::default(),
3228                json!({"id": id.to_string(), "action": "verify"}),
3229            )
3230            .await
3231            .unwrap();
3232        assert_eq!(v["valid"], false);
3233        let breaks: Vec<String> = v["breaks"]
3234            .as_array()
3235            .unwrap()
3236            .iter()
3237            .map(|b| b.as_str().unwrap().to_string())
3238            .collect();
3239        assert!(
3240            breaks.iter().any(|b| b.contains("hash-linkage")),
3241            "{breaks:?}"
3242        );
3243    }
3244
3245    #[tokio::test]
3246    async fn memory_update_applies_importance_verbatim() {
3247        // V8 S11d: class ceilings/floors live in the pipeline write gate,
3248        // the single seam every dispatch passes through (same contract as
3249        // the create path). The tool itself applies the arg verbatim, so a
3250        // direct call performs no policy — gate coverage is pinned in
3251        // `wm-dispatch/src/write_gate.rs` instead.
3252        let store = test_store();
3253
3254        let tel = Memory::new(
3255            Galaxy::Codex,
3256            "## Auto-logged Friction: dispatch error\n\nbody".into(),
3257        );
3258        store.put(Galaxy::Codex, &tel).unwrap();
3259
3260        let tool = MemoryUpdateTool::new(store.clone(), None);
3261        let mut ctx = Context::default();
3262
3263        let v = tool
3264            .call(
3265                &mut ctx,
3266                json!({"galaxy": "codex", "id": tel.metadata.id.to_string(), "importance": 0.9}),
3267            )
3268            .await
3269            .unwrap();
3270        assert!(v.get("class_policy").is_none());
3271        assert!(v.get("write_gate").is_none());
3272        let stored = store.get(Galaxy::Codex, tel.metadata.id).unwrap().unwrap();
3273        assert!((stored.metadata.importance - 0.9).abs() < 1e-5);
3274    }
3275
3276    #[tokio::test]
3277    async fn memory_search_min_trust_filter_drops_low_trust() {
3278        // V8 T-b: min_trust is a post-resolution FILTER on every route —
3279        // user-confirmed (1.0) survives a 0.9 floor, tool-ingested (0.7)
3280        // does not; the response discloses what it filtered.
3281        let (_dir, store, search) = hybrid_fixture();
3282        let mut confirmed = Memory::new(Galaxy::Codex, "Quantum foal registry minutes".into());
3283        confirmed.metadata.source_trust = 1.0;
3284        confirmed.metadata.source = "user".to_string();
3285        let mut ingested = Memory::new(Galaxy::Codex, "Quantum foal registry draft".into());
3286        ingested.metadata.source_trust = 0.7;
3287        ingested.metadata.source = "tool".to_string();
3288        store.put(Galaxy::Codex, &confirmed).unwrap();
3289        store.put(Galaxy::Codex, &ingested).unwrap();
3290        mirror_memory(&store, &confirmed, None, 1);
3291        mirror_memory(&store, &ingested, None, 2);
3292
3293        let tool = default_search_tool(store, Some(search));
3294        let mut ctx = Context::default();
3295
3296        let v = tool
3297            .call(
3298                &mut ctx,
3299                json!({"query": "quantum foal registry", "limit": 10}),
3300            )
3301            .await
3302            .unwrap();
3303        assert_eq!(v["count"], 2, "no floor: both results surface");
3304        assert!(v.get("min_trust").is_none());
3305
3306        let v = tool
3307            .call(
3308                &mut ctx,
3309                json!({"query": "quantum foal registry", "limit": 10, "min_trust": 0.9}),
3310            )
3311            .await
3312            .unwrap();
3313        assert_eq!(v["min_trust"], 0.9);
3314        assert_eq!(v["min_trust_filtered"], 1);
3315        let trusts: Vec<f64> = v["results"]
3316            .as_array()
3317            .unwrap()
3318            .iter()
3319            .map(|r| r["trust"].as_f64().unwrap())
3320            .collect();
3321        assert!(trusts.iter().all(|t| *t >= 0.9), "{trusts:?}");
3322    }
3323
3324    #[tokio::test]
3325    async fn memory_sort_by_importance_asc() {
3326        let store = test_store();
3327        populate_memories(&store, Galaxy::Codex);
3328        let tool = MemorySortTool::new(store);
3329        let mut ctx = Context::default();
3330        let v = tool
3331            .call(&mut ctx, json!({"sort_by": "importance", "order": "asc"}))
3332            .await
3333            .unwrap();
3334        let mems = v["memories"].as_array().unwrap();
3335        assert!(mems[0]["importance"].as_f64().unwrap() <= mems[1]["importance"].as_f64().unwrap());
3336    }
3337
3338    #[tokio::test]
3339    async fn memory_sort_by_recency() {
3340        let store = test_store();
3341        populate_memories(&store, Galaxy::Codex);
3342        let tool = MemorySortTool::new(store);
3343        let mut ctx = Context::default();
3344        let v = tool
3345            .call(&mut ctx, json!({"sort_by": "recency"}))
3346            .await
3347            .unwrap();
3348        assert_eq!(v["returned"], 3);
3349    }
3350
3351    #[tokio::test]
3352    async fn memory_sort_invalid_field() {
3353        let store = test_store();
3354        let tool = MemorySortTool::new(store);
3355        let mut ctx = Context::default();
3356        let result = tool.call(&mut ctx, json!({"sort_by": "invalid"})).await;
3357        assert!(result.is_err());
3358    }
3359
3360    #[tokio::test]
3361    async fn memory_sort_with_limit() {
3362        let store = test_store();
3363        populate_memories(&store, Galaxy::Codex);
3364        let tool = MemorySortTool::new(store);
3365        let mut ctx = Context::default();
3366        let v = tool.call(&mut ctx, json!({"limit": 2})).await.unwrap();
3367        assert_eq!(v["returned"], 2);
3368        assert_eq!(v["total"], 3);
3369    }
3370
3371    #[tokio::test]
3372    async fn memory_filter_by_tag() {
3373        let store = test_store();
3374        populate_memories(&store, Galaxy::Codex);
3375        let tool = MemoryFilterTool::new(store);
3376        let mut ctx = Context::default();
3377        let v = tool
3378            .call(&mut ctx, json!({"tags": ["rust"]}))
3379            .await
3380            .unwrap();
3381        assert_eq!(v["matched"], 2);
3382    }
3383
3384    #[tokio::test]
3385    async fn memory_filter_by_importance_range() {
3386        let store = test_store();
3387        populate_memories(&store, Galaxy::Codex);
3388        let tool = MemoryFilterTool::new(store);
3389        let mut ctx = Context::default();
3390        let v = tool
3391            .call(
3392                &mut ctx,
3393                json!({"min_importance": 0.4, "max_importance": 0.6}),
3394            )
3395            .await
3396            .unwrap();
3397        assert_eq!(v["matched"], 1);
3398    }
3399
3400    /// The `query` arg was silently dropped before this fix — every term
3401    /// must now match (case-insensitive) against content or title, and the
3402    /// terms are echoed in `filters` so callers can see what ran.
3403    #[tokio::test]
3404    async fn memory_filter_query_matches_content_and_title() {
3405        let store = test_store();
3406        let tool = MemoryFilterTool::new(store.clone());
3407        let mut ctx = Context::default();
3408
3409        let mut titled = Memory::new(Galaxy::Codex, "unrelated body text".into());
3410        titled.metadata.title = Some("Rust Borrow Checker".into());
3411        let plain = Memory::new(Galaxy::Codex, "rust ownership rules".into());
3412        let other = Memory::new(Galaxy::Codex, "gardening tips".into());
3413        for m in [&titled, &plain, &other] {
3414            store.put(Galaxy::Codex, m).unwrap();
3415        }
3416
3417        let v = tool.call(&mut ctx, json!({"query": "RUST"})).await.unwrap();
3418        assert_eq!(v["matched"], 2, "content hit + title hit: {v}");
3419        assert_eq!(v["filters"]["query_terms"], json!(["rust"]));
3420
3421        let v = tool
3422            .call(&mut ctx, json!({"query": "rust borrow"}))
3423            .await
3424            .unwrap();
3425        assert_eq!(v["matched"], 1, "all terms must match: {v}");
3426        assert_eq!(v["memories"][0]["content"], "unrelated body text");
3427    }
3428
3429    #[tokio::test]
3430    async fn memory_filter_no_matches() {
3431        let store = test_store();
3432        populate_memories(&store, Galaxy::Codex);
3433        let tool = MemoryFilterTool::new(store);
3434        let mut ctx = Context::default();
3435        let v = tool
3436            .call(&mut ctx, json!({"tags": ["nonexistent"]}))
3437            .await
3438            .unwrap();
3439        assert_eq!(v["matched"], 0);
3440    }
3441
3442    #[tokio::test]
3443    async fn memory_filter_combined_tags_and_importance() {
3444        let store = test_store();
3445        populate_memories(&store, Galaxy::Codex);
3446        let tool = MemoryFilterTool::new(store);
3447        let mut ctx = Context::default();
3448        let v = tool
3449            .call(&mut ctx, json!({"tags": ["rust"], "min_importance": 0.5}))
3450            .await
3451            .unwrap();
3452        assert_eq!(v["matched"], 1);
3453    }
3454
3455    /// API honesty (§8): `offset`, `exclude_tags`, and the date range the
3456    /// description always promised are real. Paging addresses the VISIBLE
3457    /// surface.
3458    #[tokio::test]
3459    async fn memory_filter_offset_exclude_tags_and_date_range() {
3460        let store = test_store();
3461        let tool = MemoryFilterTool::new(store.clone());
3462        let mut ctx = Context::default();
3463
3464        let mut recent_a = Memory::new(Galaxy::Codex, "recent a".into());
3465        recent_a.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(2);
3466        let mut recent_b = Memory::new(Galaxy::Codex, "recent b".into());
3467        recent_b.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
3468        recent_b.metadata.tags = vec!["noise".into()];
3469        let mut recent_priv = Memory::new(Galaxy::Codex, "recent private".into());
3470        recent_priv.metadata.created_at = chrono::Utc::now() - chrono::Duration::minutes(90);
3471        recent_priv.metadata.is_private = true;
3472        let mut old = Memory::new(Galaxy::Codex, "old relic".into());
3473        old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
3474        for m in [&recent_a, &recent_b, &recent_priv, &old] {
3475            store.put(Galaxy::Codex, m).unwrap();
3476        }
3477
3478        let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
3479            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
3480
3481        // Date range + exclude_tags + privacy all compose.
3482        let v = tool
3483            .call(
3484                &mut ctx,
3485                json!({
3486                    "galaxy": "codex",
3487                    "created_after": cutoff,
3488                    "exclude_tags": ["noise"],
3489                }),
3490            )
3491            .await
3492            .unwrap();
3493        assert_eq!(v["matched"], 1, "only recent-a is visible in range: {v}");
3494        assert_eq!(v["returned"], 1);
3495        assert_eq!(v["memories"][0]["content"], "recent a");
3496        assert_eq!(v["filters"]["exclude_tags"], json!(["noise"]));
3497        assert!(v["filters"]["created_after"].is_string());
3498
3499        // Offset pages the matched surface (recent-a, recent-b, old —
3500        // the private memory is invisible and never counted).
3501        let page2 = tool
3502            .call(
3503                &mut ctx,
3504                json!({"galaxy": "codex", "offset": 3, "limit": 2}),
3505            )
3506            .await
3507            .unwrap();
3508        assert_eq!(
3509            page2["matched"], 3,
3510            "private memory must not count: {page2}"
3511        );
3512        assert_eq!(
3513            page2["returned"], 0,
3514            "offset past the match set is an honest empty page"
3515        );
3516        assert_eq!(page2["offset"], 3);
3517
3518        // Malformed date bounds are a loud InvalidArgs.
3519        let bad = tool
3520            .call(
3521                &mut ctx,
3522                json!({"galaxy": "codex", "created_before": "yesterday"}),
3523            )
3524            .await;
3525        assert!(bad.is_err(), "non-RFC-3339 bound must be refused");
3526    }
3527
3528    #[tokio::test]
3529    async fn memory_deduplicate_hash_dry_run() {
3530        let store = test_store();
3531        let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3532        let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3533        let _ = store.put(Galaxy::Codex, &m1);
3534        let _ = store.put(Galaxy::Codex, &m2);
3535        let _ = store.put(
3536            Galaxy::Codex,
3537            &Memory::new(Galaxy::Codex, "unique content".into()),
3538        );
3539
3540        let tool = MemoryDeduplicateTool::new(store.clone(), None);
3541        let mut ctx = Context::default();
3542        let v = tool
3543            .call(&mut ctx, json!({"mode": "hash", "dry_run": true}))
3544            .await
3545            .unwrap();
3546        assert_eq!(v["duplicates_found"], 1);
3547        assert_eq!(v["removed"], 0);
3548
3549        let memories = store.scan(Galaxy::Codex, 100).unwrap();
3550        assert_eq!(memories.len(), 3);
3551    }
3552
3553    #[tokio::test]
3554    async fn memory_deduplicate_hash_execute() {
3555        let store = test_store();
3556        let m1 = Memory::new(Galaxy::Codex, "duplicate content".into());
3557        let m2 = Memory::new(Galaxy::Codex, "duplicate content".into());
3558        let _ = store.put(Galaxy::Codex, &m1);
3559        let _ = store.put(Galaxy::Codex, &m2);
3560        let _ = store.put(
3561            Galaxy::Codex,
3562            &Memory::new(Galaxy::Codex, "unique content".into()),
3563        );
3564
3565        let tool = MemoryDeduplicateTool::new(store.clone(), None);
3566        let mut ctx = Context::default();
3567        let v = tool
3568            .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3569            .await
3570            .unwrap();
3571        assert_eq!(v["duplicates_found"], 1);
3572        assert_eq!(v["removed"], 1);
3573
3574        let memories = store.scan(Galaxy::Codex, 100).unwrap();
3575        assert_eq!(memories.len(), 2);
3576    }
3577
3578    #[tokio::test]
3579    async fn memory_deduplicate_deindexes_removed_memories() {
3580        // Regression: deduplicate used to delete from LMDB without de-indexing,
3581        // so full-text search kept returning the removed duplicate.
3582        let (_dir, store, search) = hybrid_fixture();
3583
3584        let m1 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3585        let m2 = Memory::new(Galaxy::Codex, "index drift duplicate".into());
3586        let id1 = m1.metadata.id;
3587        let id2 = m2.metadata.id;
3588        let _ = store.put(Galaxy::Codex, &m1);
3589        let _ = store.put(Galaxy::Codex, &m2);
3590        for mem in [&m1, &m2] {
3591            let mut writer = search.writer().unwrap();
3592            search
3593                .add_document(
3594                    &mut writer,
3595                    &mem.metadata.id.to_string(),
3596                    mem.metadata.galaxy.db_name(),
3597                    &mem.content,
3598                    &mem.metadata.tags,
3599                    mem.metadata.created_at.timestamp(),
3600                )
3601                .unwrap();
3602            search.commit(&mut writer).unwrap();
3603        }
3604
3605        // Precondition: both duplicates are searchable.
3606        let before = search.search_ids("index drift", 100).unwrap();
3607        assert_eq!(before.len(), 2);
3608
3609        let tool = MemoryDeduplicateTool::new(store.clone(), Some(search.clone()));
3610        let mut ctx = Context::default();
3611        let v = tool
3612            .call(&mut ctx, json!({"mode": "hash", "dry_run": false}))
3613            .await
3614            .unwrap();
3615        assert_eq!(v["removed"], 1);
3616
3617        // The surviving memory is still searchable; the removed one is gone.
3618        // (LMDB scan order is by UUID, so either duplicate may be the keeper.)
3619        let after = search.search_ids("index drift", 100).unwrap();
3620        assert_eq!(after.len(), 1, "search index should only contain survivors");
3621        assert!(
3622            after.contains(&id1) || after.contains(&id2),
3623            "survivor should be one of the original memories"
3624        );
3625    }
3626
3627    #[tokio::test]
3628    async fn memory_deduplicate_content_mode() {
3629        let store = test_store();
3630        let m1 = Memory::new(Galaxy::Codex, "same text".into());
3631        let m2 = Memory::new(Galaxy::Codex, "same text".into());
3632        let _ = store.put(Galaxy::Codex, &m1);
3633        let _ = store.put(Galaxy::Codex, &m2);
3634
3635        let tool = MemoryDeduplicateTool::new(store, None);
3636        let mut ctx = Context::default();
3637        let v = tool
3638            .call(&mut ctx, json!({"mode": "content", "dry_run": true}))
3639            .await
3640            .unwrap();
3641        assert_eq!(v["duplicates_found"], 1);
3642    }
3643
3644    #[tokio::test]
3645    async fn memory_deduplicate_no_duplicates() {
3646        let store = test_store();
3647        let _ = store.put(
3648            Galaxy::Codex,
3649            &Memory::new(Galaxy::Codex, "content a".into()),
3650        );
3651        let _ = store.put(
3652            Galaxy::Codex,
3653            &Memory::new(Galaxy::Codex, "content b".into()),
3654        );
3655
3656        let tool = MemoryDeduplicateTool::new(store, None);
3657        let mut ctx = Context::default();
3658        let v = tool.call(&mut ctx, json!({})).await.unwrap();
3659        assert_eq!(v["duplicates_found"], 0);
3660    }
3661
3662    #[tokio::test]
3663    async fn memory_deduplicate_invalid_mode() {
3664        let store = test_store();
3665        let tool = MemoryDeduplicateTool::new(store, None);
3666        let mut ctx = Context::default();
3667        let result = tool.call(&mut ctx, json!({"mode": "invalid"})).await;
3668        assert!(result.is_err());
3669    }
3670
3671    #[tokio::test]
3672    async fn memory_export_json() {
3673        let store = test_store();
3674        populate_memories(&store, Galaxy::Codex);
3675        let tool = MemoryExportTool::new(store);
3676        let mut ctx = Context::default();
3677        let v = tool
3678            .call(&mut ctx, json!({"format": "json"}))
3679            .await
3680            .unwrap();
3681        assert_eq!(v["format"], "json");
3682        assert_eq!(v["count"], 3);
3683        assert!(v["export"].as_str().unwrap().contains("First memory"));
3684    }
3685
3686    #[tokio::test]
3687    async fn memory_export_csv() {
3688        let store = test_store();
3689        populate_memories(&store, Galaxy::Codex);
3690        let tool = MemoryExportTool::new(store);
3691        let mut ctx = Context::default();
3692        let v = tool.call(&mut ctx, json!({"format": "csv"})).await.unwrap();
3693        let csv = v["export"].as_str().unwrap();
3694        assert!(csv.contains("id,content,tags"));
3695        assert!(csv.contains("First memory"));
3696    }
3697
3698    #[tokio::test]
3699    async fn memory_export_markdown() {
3700        let store = test_store();
3701        populate_memories(&store, Galaxy::Codex);
3702        let tool = MemoryExportTool::new(store);
3703        let mut ctx = Context::default();
3704        let v = tool
3705            .call(&mut ctx, json!({"format": "markdown"}))
3706            .await
3707            .unwrap();
3708        let md = v["export"].as_str().unwrap();
3709        assert!(md.contains("# Memory Export"));
3710        assert!(md.contains("First memory"));
3711    }
3712
3713    #[tokio::test]
3714    async fn memory_export_invalid_format() {
3715        let store = test_store();
3716        let tool = MemoryExportTool::new(store);
3717        let mut ctx = Context::default();
3718        let result = tool.call(&mut ctx, json!({"format": "xml"})).await;
3719        assert!(result.is_err());
3720    }
3721
3722    #[tokio::test]
3723    async fn memory_export_empty_galaxy() {
3724        let store = test_store();
3725        let tool = MemoryExportTool::new(store);
3726        let mut ctx = Context::default();
3727        let v = tool
3728            .call(&mut ctx, json!({"format": "json"}))
3729            .await
3730            .unwrap();
3731        assert_eq!(v["count"], 0);
3732    }
3733
3734    #[tokio::test]
3735    async fn memory_sort_and_filter_are_winnowing_basket_gana() {
3736        let store = test_store();
3737        assert_eq!(
3738            MemorySortTool::new(store.clone()).gana(),
3739            Gana::WinnowingBasket
3740        );
3741        assert_eq!(
3742            MemoryFilterTool::new(store.clone()).gana(),
3743            Gana::WinnowingBasket
3744        );
3745        assert_eq!(
3746            MemoryDeduplicateTool::new(store.clone(), None).gana(),
3747            Gana::WinnowingBasket
3748        );
3749        assert_eq!(MemoryExportTool::new(store).gana(), Gana::WinnowingBasket);
3750    }
3751
3752    // ── hybrid_recall incident regression tests ─────────────────────────
3753    //
3754    // Mirrors the 2026-08-11 incident: `memory.hybrid_recall` with query
3755    // "smoke test from wmClient" and limit 20 returned 20 unrelated memories
3756    // at BM25 scores 0.5–1.0 with zero query-token overlap. The fix uses
3757    // OR semantics with a token-coverage floor and score floors, replacing
3758    // the old `scan(galaxy, 100)` lottery.
3759
3760    #[test]
3761    fn hybrid_recall_routes_expose_query_schema() {
3762        let dir = tempfile::tempdir().unwrap();
3763        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3764        for tool in [
3765            MemoryHybridRecallTool::new(store.clone(), None, None),
3766            MemoryHybridRecallTool::as_search(store, None, None),
3767        ] {
3768            let schema = tool.input_schema();
3769            assert_eq!(schema["type"], "object");
3770            assert!(schema["properties"].get("query").is_some());
3771            assert_eq!(schema["required"], json!(["query"]));
3772        }
3773    }
3774
3775    /// Build a store + tantivy index pair where the memory and its index
3776    /// document are kept in sync (as the write path does).
3777    fn hybrid_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
3778        let dir = tempfile::tempdir().unwrap();
3779        let store = Arc::new(MemoryStore::open_default(dir.path()).unwrap());
3780        let tantivy_dir = dir.path().join("tantivy");
3781        std::fs::create_dir_all(&tantivy_dir).unwrap();
3782        let search = Arc::new(SearchEngine::open(&tantivy_dir).unwrap());
3783        (dir, store, search)
3784    }
3785
3786    fn index_memory(
3787        store: &Arc<MemoryStore>,
3788        search: &Arc<SearchEngine>,
3789        galaxy: Galaxy,
3790        content: &str,
3791    ) {
3792        let mem = Memory::new(galaxy, content.to_string());
3793        let id = mem.metadata.id;
3794        store.put(galaxy, &mem).unwrap();
3795        let mut writer = search.writer().unwrap();
3796        search
3797            .add_document(
3798                &mut writer,
3799                &id.to_string(),
3800                galaxy.db_name(),
3801                content,
3802                &mem.metadata.tags,
3803                mem.metadata.created_at.timestamp(),
3804            )
3805            .unwrap();
3806        search.commit(&mut writer).unwrap();
3807    }
3808
3809    #[tokio::test]
3810    async fn hybrid_recall_excludes_private_memories() {
3811        let (_dir, store, search) = hybrid_fixture();
3812
3813        // Private memory, indexed exactly like the write path.
3814        let mut priv_mem = Memory::new(Galaxy::Codex, "private secret plan alpha".to_string());
3815        priv_mem.metadata.is_private = true;
3816        let id = priv_mem.metadata.id;
3817        store.put(Galaxy::Codex, &priv_mem).unwrap();
3818        {
3819            let mut writer = search.writer().unwrap();
3820            search
3821                .add_document(
3822                    &mut writer,
3823                    &id.to_string(),
3824                    "codex",
3825                    "private secret plan alpha",
3826                    &[],
3827                    priv_mem.metadata.created_at.timestamp(),
3828                )
3829                .unwrap();
3830            search.commit(&mut writer).unwrap();
3831        }
3832
3833        // Public memory with overlapping terms.
3834        index_memory(
3835            &store,
3836            &search,
3837            Galaxy::Codex,
3838            "public plan alpha documentation",
3839        );
3840
3841        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
3842        let v = tool
3843            .call(
3844                &mut Context::default(),
3845                json!({"query": "plan alpha", "galaxy": "codex"}),
3846            )
3847            .await
3848            .unwrap();
3849        let results = v["results"].as_array().unwrap();
3850        let contents: Vec<&str> = results
3851            .iter()
3852            .filter_map(|r| r["content"].as_str())
3853            .collect();
3854        assert!(
3855            !contents.iter().any(|c| c.contains("private")),
3856            "private memory leaked through hybrid recall: {results:?}"
3857        );
3858        assert!(
3859            contents.iter().any(|c| c.contains("public")),
3860            "public memory missing from hybrid recall: {results:?}"
3861        );
3862    }
3863
3864    #[tokio::test]
3865    async fn batch_read_treats_private_as_miss() {
3866        let store = test_store();
3867        let mut priv_mem = Memory::new(Galaxy::Codex, "private batch note".into());
3868        priv_mem.metadata.is_private = true;
3869        let priv_id = priv_mem.metadata.id;
3870        store.put(Galaxy::Codex, &priv_mem).unwrap();
3871        let pub_mem = Memory::new(Galaxy::Codex, "public batch note".into());
3872        let pub_id = pub_mem.metadata.id;
3873        store.put(Galaxy::Codex, &pub_mem).unwrap();
3874
3875        let tool = MemoryBatchReadTool::new(store);
3876        let v = tool
3877            .call(
3878                &mut Context::default(),
3879                json!({"galaxy": "codex", "ids": [priv_id.to_string(), pub_id.to_string()]}),
3880            )
3881            .await
3882            .unwrap();
3883        assert_eq!(v["found"], 1);
3884        assert_eq!(v["misses"], 1);
3885        assert!(
3886            !v["memories"]
3887                .as_array()
3888                .unwrap()
3889                .iter()
3890                .any(|m| m["content"].as_str().unwrap_or("").contains("private")),
3891            "private memory leaked through batch_read: {v}"
3892        );
3893    }
3894
3895    #[tokio::test]
3896    async fn hybrid_recall_incident_query_returns_only_relevant() {
3897        let (_dir, store, search) = hybrid_fixture();
3898        index_memory(
3899            &store,
3900            &search,
3901            Galaxy::Codex,
3902            "smoke test from wmClient: verify recall",
3903        );
3904        index_memory(
3905            &store,
3906            &search,
3907            Galaxy::Codex,
3908            "NES Evolution and Impact: a history of the console wars",
3909        );
3910        index_memory(
3911            &store,
3912            &search,
3913            Galaxy::Codex,
3914            "Insights on The Gateless Gate: koans and zen practice",
3915        );
3916        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3917        let mut ctx = Context::default();
3918        let v = tool
3919            .call(
3920                &mut ctx,
3921                json!({"query": "smoke test", "galaxy": "codex", "limit": 5}),
3922            )
3923            .await
3924            .unwrap();
3925        let results = v["results"].as_array().unwrap();
3926        assert_eq!(
3927            results.len(),
3928            1,
3929            "incident query must not return unrelated memories: {results:?}"
3930        );
3931        let hit = &results[0];
3932        assert_eq!(hit["source"], "fts");
3933        assert!(
3934            hit["content"]
3935                .as_str()
3936                .unwrap()
3937                .contains("smoke test from wmClient")
3938        );
3939        assert!(hit["normalized_score"].as_f64().unwrap() > 0.0);
3940        assert_eq!(v["count"], 1);
3941    }
3942
3943    #[tokio::test]
3944    async fn hybrid_recall_filters_stale_index_entries() {
3945        // A document indexed in tantivy but absent from LMDB must not be
3946        // returned (the old code wasted top-K slots on these).
3947        let (_dir, store, search) = hybrid_fixture();
3948        index_memory(
3949            &store,
3950            &search,
3951            Galaxy::Codex,
3952            "rust memory about ownership",
3953        );
3954        {
3955            let mut writer = search.writer().unwrap();
3956            search
3957                .add_document(
3958                    &mut writer,
3959                    "99999999-9999-9999-9999-999999999999",
3960                    "codex",
3961                    "rust ghost memory",
3962                    &[],
3963                    1700000000,
3964                )
3965                .unwrap();
3966            search.commit(&mut writer).unwrap();
3967        }
3968
3969        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
3970        let mut ctx = Context::default();
3971        let v = tool
3972            .call(&mut ctx, json!({"query": "rust", "limit": 10}))
3973            .await
3974            .unwrap();
3975        let results = v["results"].as_array().unwrap();
3976        assert_eq!(results.len(), 1);
3977        assert_ne!(
3978            results[0]["id"].as_str().unwrap(),
3979            "99999999-9999-9999-9999-999999999999"
3980        );
3981    }
3982
3983    #[tokio::test]
3984    async fn hybrid_recall_respects_min_score_arg() {
3985        let (_dir, store, search) = hybrid_fixture();
3986        index_memory(&store, &search, Galaxy::Codex, "alpha");
3987        let filler = format!("alpha {}", "zzz ".repeat(400));
3988        index_memory(&store, &search, Galaxy::Codex, &filler);
3989
3990        // No threshold: both match.
3991        let tool = MemoryHybridRecallTool::new(store.clone(), Some(search.clone()), None);
3992        let mut ctx = Context::default();
3993        let v = tool
3994            .call(&mut ctx, json!({"query": "alpha", "limit": 10}))
3995            .await
3996            .unwrap();
3997        assert_eq!(v["count"], 2);
3998
3999        // min_score between the two scores: only the strong match remains.
4000        let scores: Vec<f64> = v["results"]
4001            .as_array()
4002            .unwrap()
4003            .iter()
4004            .map(|r| r["score"].as_f64().unwrap())
4005            .collect();
4006        let lo = scores.iter().copied().fold(f64::MAX, f64::min);
4007        let hi = scores.iter().copied().fold(0.0, f64::max);
4008        let mid = f64::midpoint(hi, lo);
4009
4010        let v = tool
4011            .call(
4012                &mut ctx,
4013                json!({"query": "alpha", "limit": 10, "min_score": mid}),
4014            )
4015            .await
4016            .unwrap();
4017        assert_eq!(v["count"], 1);
4018        assert!((v["results"][0]["score"].as_f64().unwrap() - hi).abs() < 1e-3);
4019    }
4020
4021    #[tokio::test]
4022    async fn hybrid_recall_or_coverage_finds_partial_matches() {
4023        // OR + token-coverage finds partial matches without a separate
4024        // fallback phase.  The doc covering 3/4 query terms survives the
4025        // 2/4 coverage floor; the 1/4 doc is filtered out.
4026        let (_dir, store, search) = hybrid_fixture();
4027        index_memory(&store, &search, Galaxy::Codex, "alpha only here");
4028        index_memory(&store, &search, Galaxy::Codex, "alpha beta gamma delta");
4029
4030        let tool = MemoryHybridRecallTool::new(store, Some(search), None);
4031        let mut ctx = Context::default();
4032        let v = tool
4033            .call(&mut ctx, json!({"query": "alpha beta gamma", "limit": 10}))
4034            .await
4035            .unwrap();
4036        let results = v["results"].as_array().unwrap();
4037        // 3-term query: 2/3 coverage floor.  "alpha beta gamma delta" covers
4038        // 3/3, "alpha only here" covers 1/3 → filtered.
4039        assert_eq!(results.len(), 1);
4040        assert!(
4041            results[0]["content"]
4042                .as_str()
4043                .unwrap()
4044                .contains("alpha beta gamma")
4045        );
4046
4047        // 4-term query: 2/4 coverage floor.  "alpha beta gamma delta" covers
4048        // 3/4, "alpha only here" covers 1/4 → filtered.
4049        let v = tool
4050            .call(
4051                &mut ctx,
4052                json!({"query": "alpha beta gamma zeta", "limit": 10}),
4053            )
4054            .await
4055            .unwrap();
4056        let results = v["results"].as_array().unwrap();
4057        assert_eq!(
4058            results.len(),
4059            1,
4060            "OR + coverage must require 2/4 token coverage: {results:?}"
4061        );
4062        assert!(
4063            results[0]["content"]
4064                .as_str()
4065                .unwrap()
4066                .contains("alpha beta gamma")
4067        );
4068        for r in results {
4069            assert!(
4070                matches!(r["source"].as_str(), Some("fts")),
4071                "results should be tagged fts"
4072            );
4073        }
4074    }
4075
4076    fn index_tagged_memory(
4077        store: &Arc<MemoryStore>,
4078        search: &Arc<SearchEngine>,
4079        galaxy: Galaxy,
4080        content: &str,
4081        tags: &[&str],
4082    ) {
4083        let mut mem = Memory::new(galaxy, content.to_string());
4084        mem.metadata.tags = tags.iter().map(ToString::to_string).collect();
4085        let id = mem.metadata.id;
4086        store.put(galaxy, &mem).unwrap();
4087        let mut writer = search.writer().unwrap();
4088        search
4089            .add_document(
4090                &mut writer,
4091                &id.to_string(),
4092                galaxy.db_name(),
4093                content,
4094                &mem.metadata.tags,
4095                mem.metadata.created_at.timestamp(),
4096            )
4097            .unwrap();
4098        search.commit(&mut writer).unwrap();
4099    }
4100
4101    fn aggregate_fixture() -> (tempfile::TempDir, Arc<MemoryStore>, Arc<SearchEngine>) {
4102        let (dir, store, search) = hybrid_fixture();
4103        // A Rust learning journey across sessions 2, 7, 12 …
4104        index_tagged_memory(
4105            &store,
4106            &search,
4107            Galaxy::Codex,
4108            "I started learning Rust.",
4109            &["user", "session_002"],
4110        );
4111        index_tagged_memory(
4112            &store,
4113            &search,
4114            Galaxy::Codex,
4115            "I finished my first Rust project, a CLI tool.",
4116            &["user", "session_007"],
4117        );
4118        index_tagged_memory(
4119            &store,
4120            &search,
4121            Galaxy::Codex,
4122            "I got a job as a systems engineer using Rust.",
4123            &["user", "session_012"],
4124        );
4125        // …and a Go journey that must not distort the Rust span (all its
4126        // turns also match the generic terms "started"/"job").
4127        index_tagged_memory(
4128            &store,
4129            &search,
4130            Galaxy::Codex,
4131            "I started learning Go.",
4132            &["user", "session_003"],
4133        );
4134        index_tagged_memory(
4135            &store,
4136            &search,
4137            Galaxy::Codex,
4138            "I got a job as a backend engineer using Go.",
4139            &["user", "session_015"],
4140        );
4141        (dir, store, search)
4142    }
4143
4144    #[tokio::test]
4145    async fn aggregate_session_span_isolated_by_rarest_term() {
4146        let (_dir, store, search) = aggregate_fixture();
4147        let tool = MemoryAggregateTool::new(Some(search), store);
4148        let mut ctx = Context::default();
4149        let v = tool
4150            .call(
4151                &mut ctx,
4152                json!({
4153                    "query": "How long did it take from starting Rust to getting a job using it?",
4154                    "metric": "session_span",
4155                }),
4156            )
4157            .await
4158            .unwrap();
4159        assert_eq!(v["aggregate"]["value"], 10, "session_012 - session_002");
4160        assert_eq!(v["aggregate"]["unit"], "sessions");
4161        assert_eq!(v["aggregate"]["content"], "10 sessions");
4162    }
4163
4164    #[tokio::test]
4165    async fn aggregate_session_count() {
4166        let (_dir, store, search) = aggregate_fixture();
4167        let tool = MemoryAggregateTool::new(Some(search), store);
4168        let mut ctx = Context::default();
4169        let v = tool
4170            .call(
4171                &mut ctx,
4172                json!({
4173                    "query": "How long did it take from starting Rust to getting a job using it?",
4174                    "metric": "session_count",
4175                }),
4176            )
4177            .await
4178            .unwrap();
4179        // The anchor cluster is the Rust turns; the middle turn ("finished
4180        // my first Rust project") matches only one query term and is held
4181        // back by the search engine's token-coverage floor, so the distinct
4182        // session count is 2 (start and end sessions) — span is unaffected
4183        // because min/max need only the endpoints.
4184        assert_eq!(v["aggregate"]["value"], 2);
4185    }
4186
4187    #[tokio::test]
4188    async fn aggregate_count_needs_no_session_tags() {
4189        let (_dir, store, search) = aggregate_fixture();
4190        let tool = MemoryAggregateTool::new(Some(search), store);
4191        let mut ctx = Context::default();
4192        let v = tool
4193            .call(
4194                &mut ctx,
4195                json!({"query": "Rust project", "metric": "count"}),
4196            )
4197            .await
4198            .unwrap();
4199        // OR semantics: all three Rust turns match "rust".
4200        assert_eq!(v["aggregate"]["value"], 3);
4201    }
4202
4203    /// Previously a single session-tagged hit (or a stopword-only query
4204    /// with no anchor term) reported session_count 0 / span null DESPITE
4205    /// evidence. The fallback anchors on the session-tagged set instead.
4206    #[tokio::test]
4207    async fn aggregate_single_session_falls_back_honestly() {
4208        let (_dir, store, search) = aggregate_fixture();
4209        let tool = MemoryAggregateTool::new(Some(search), store);
4210        let mut ctx = Context::default();
4211        // "CLI tool" narrows to the session_007 turn alone.
4212        let v = tool
4213            .call(
4214                &mut ctx,
4215                json!({"query": "CLI tool", "metric": "session_count"}),
4216            )
4217            .await
4218            .unwrap();
4219        assert_eq!(v["aggregate"]["value"], 1, "one session in evidence: {v}");
4220        assert_eq!(v["anchor"], "session_tagged_fallback");
4221        let v = tool
4222            .call(
4223                &mut ctx,
4224                json!({"query": "CLI tool", "metric": "session_span"}),
4225            )
4226            .await
4227            .unwrap();
4228        assert_eq!(v["aggregate"]["value"], 0, "single point spans 0: {v}");
4229    }
4230
4231    #[tokio::test]
4232    async fn aggregate_rejects_unknown_metric() {
4233        let (_dir, store, search) = aggregate_fixture();
4234        let tool = MemoryAggregateTool::new(Some(search), store);
4235        let mut ctx = Context::default();
4236        let err = tool
4237            .call(&mut ctx, json!({"query": "x", "metric": "median"}))
4238            .await
4239            .unwrap_err();
4240        assert!(err.to_string().contains("unknown metric"));
4241    }
4242}