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