Skip to main content

wm_tools/expansion/
memory_ops.rs

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