Skip to main content

wm_tools/expansion/
memory_ops.rs

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