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