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