Skip to main content

wm_tools/expansion/
memory_ops.rs

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