Skip to main content

wm_tools/
lib.rs

1//! WhiteMagic v5 Tools — 229 tools + fractal meta-tool
2//!
3//! Tools: memory.create, memory.read, memory.list, memory.delete,
4//! memory.query, memory.search, memory.associate, memory.associations,
5//! gnosis, tools.list, karma.report, dharma.status, and the `wm` meta-tool.
6
7#![forbid(unsafe_code)]
8#![allow(clippy::significant_drop_tightening)]
9
10pub mod embedding_router;
11pub mod expansion;
12pub mod nlu;
13pub mod profiles;
14
15use async_trait::async_trait;
16
17use std::sync::Arc;
18
19use serde_json::{Value, json};
20use wm_cognitive::GanYingBus;
21use wm_core::{
22    Capability, Context, EffectRow, EpisodicCapturePolicy, EpisodicKind, EpisodicRecord, Galaxy,
23    Gana, Provenance, ProvenanceSource, Resource, Tool, ToolStats,
24};
25use wm_dispatch::{DispatchPipeline, ToolRegistry, ToolRegistryBuilder};
26use wm_governance::{DharmaGate, KarmaLedger, ResourceRules};
27use wm_memory::{
28    Association, AssociationStore, ConversationalSearch, Memory, MemoryQuery, MemoryStore,
29    RecallEngine, SearchEngine, VectorStore,
30};
31use wm_substrate::SubstrateMonitor;
32use wm_substrate::anomaly::AnomalyDetector;
33use wm_substrate::homeostatic::HomeostaticLoop;
34use wm_substrate::sensorimotor::{ReflexLoop, SensorimotorBus};
35
36use crate::expansion::common::{
37    bool_prop, fresh_write_galaxies, int_prop, memory_galaxy_reads, memory_galaxy_writes, num_prop,
38    schema, str_array_prop, str_prop,
39};
40
41/// Minimum confidence for NLU routing to dispatch. Below this, the router
42/// abstains and returns an error suggesting explicit routing instead of
43/// dispatching to the wrong tool. Only applies to `thought=` (NLU) routing,
44/// not explicit `route=`.
45const NLU_ABSTENTION_THRESHOLD: f64 = 0.15;
46
47/// Mirror an explicit v5 memory write into the v6 episodic lane.
48///
49/// The mirror is additive and non-fatal: a legacy memory write must not fail
50/// because the new cognitive scaffold is unavailable.
51fn capture_explicit_memory(
52    store: &MemoryStore,
53    memory: &Memory,
54    kind: EpisodicKind,
55    source: ProvenanceSource,
56    session_id: Option<uuid::Uuid>,
57    sequence: u64,
58) {
59    let record = explicit_memory_record(memory, kind, source, session_id, sequence);
60    if let Err(error) = store
61        .episodic()
62        .append_explicit(&record, EpisodicCapturePolicy::explicit_only())
63    {
64        tracing::warn!(
65            memory_id = %memory.metadata.id,
66            "episodic capture failed after legacy write: {error}"
67        );
68    }
69}
70
71fn explicit_memory_record(
72    memory: &Memory,
73    kind: EpisodicKind,
74    source: ProvenanceSource,
75    session_id: Option<uuid::Uuid>,
76    sequence: u64,
77) -> EpisodicRecord {
78    let resolved_kind = resolve_episodic_kind(memory, kind);
79    EpisodicRecord::new(
80        session_id,
81        sequence,
82        resolved_kind,
83        memory.content.clone(),
84        Provenance::new(source),
85    )
86    .with_id(memory.metadata.id)
87    .with_visibility(memory.metadata.is_private, memory.metadata.model_exclude)
88}
89
90/// Override the default `EpisodicKind` when the memory tags carry role
91/// information (e.g. `"user"` or `"assistant"` from the benchmark adapter).
92fn resolve_episodic_kind(memory: &Memory, default: EpisodicKind) -> EpisodicKind {
93    let tags = &memory.metadata.tags;
94    if tags.iter().any(|t| t == "user") {
95        EpisodicKind::UserStatement
96    } else if tags.iter().any(|t| t == "assistant") {
97        EpisodicKind::AssistantResponse
98    } else {
99        default
100    }
101}
102
103fn capture_explicit_memories(
104    store: &MemoryStore,
105    memories: &[(Galaxy, Memory)],
106    kind: EpisodicKind,
107    source: ProvenanceSource,
108    session_id: Option<uuid::Uuid>,
109) {
110    if memories.is_empty() {
111        return;
112    }
113    let records: Vec<EpisodicRecord> = memories
114        .iter()
115        .enumerate()
116        .map(|(sequence, (_, memory))| {
117            explicit_memory_record(memory, kind, source, session_id, sequence as u64)
118        })
119        .collect();
120    if let Err(error) = store
121        .episodic()
122        .append_explicit_batch(&records, EpisodicCapturePolicy::explicit_only())
123    {
124        tracing::warn!("episodic batch capture failed after legacy write: {error}");
125    }
126}
127
128// ── Tool: memory.create ──────────────────────────────────────────────
129
130// ── Creation attestations (Track F Slice A, D5) ──────────────────────────
131
132/// Agent attribution for an attestation: dispatch session UUID when inside
133/// one, client-asserted user id when set, else `"local"`.
134fn attestation_agent_id(ctx: &Context) -> String {
135    ctx.session_id
136        .map(|u| u.to_string())
137        .or_else(|| ctx.user_id.clone())
138        .unwrap_or_else(|| "local".to_string())
139}
140
141/// Read the node signing key for creation attestations. `None` (unset or
142/// blank) is normal on keyless nodes — the tool discloses `attested: false`
143/// instead of failing.
144fn node_attestation_key() -> Option<String> {
145    std::env::var(wm_memory::attestation::ATTESTATION_KEY_ENV)
146        .ok()
147        .filter(|k| !k.trim().is_empty())
148}
149
150/// Attempt to attest one created memory with an explicit key (the
151/// `with_armed`-style seam: production passes the env-read key, tests pass
152/// fixed keys — env is process-global and this crate forbids `unsafe`, so
153/// tests never mutate it). Absence or invalidity is honest and never fatal:
154/// the create already succeeded, attestation is evidence, not a gate.
155/// Returns `(attested, reason)` — reason is `Some` exactly when false.
156fn attest_created_memory(
157    store: &MemoryStore,
158    galaxy: Galaxy,
159    id: uuid::Uuid,
160    record_hash: &str,
161    ctx: &Context,
162    key_hex: Option<&str>,
163) -> (bool, Option<String>) {
164    let key_hex = match key_hex {
165        Some(k) if !k.trim().is_empty() => k,
166        _ => return (false, Some("node key unavailable".to_string())),
167    };
168    let agent_id = attestation_agent_id(ctx);
169    let timestamp = wm_core::time::now_unix_secs();
170    let payload = wm_memory::attestation::attestation_payload(
171        galaxy.db_name(),
172        &id.to_string(),
173        record_hash,
174        &agent_id,
175        timestamp,
176    );
177    let Some((public_key_hex, signature_hex)) =
178        wm_memory::attestation::sign_attestation(&payload, key_hex)
179    else {
180        tracing::warn!("creation attestation skipped for memory {id}: key material invalid");
181        return (false, Some("node key invalid".to_string()));
182    };
183    let entry = wm_memory::attestation::RecordAttestation {
184        domain: wm_memory::attestation::ATTESTATION_DOMAIN.to_string(),
185        galaxy: galaxy.db_name().to_string(),
186        memory_id: id.to_string(),
187        record_hash: record_hash.to_string(),
188        agent_id,
189        timestamp,
190        public_key_hex,
191        signature_hex,
192    };
193    if let Err(e) = store.record_attestation(galaxy, id, &entry) {
194        tracing::warn!("creation attestation write failed for memory {id}: {e}");
195        return (false, Some("attestation store write failed".to_string()));
196    }
197    (true, None)
198}
199
200/// Create a memory in a galaxy.
201///
202/// If a `SearchEngine` is provided, the memory is also indexed into Tantivy
203/// for full-text search immediately after the LMDB write.
204pub struct MemoryCreateTool {
205    store: Arc<MemoryStore>,
206    search: Option<Arc<SearchEngine>>,
207    recall: Option<Arc<RecallEngine>>,
208    stats: ToolStats,
209    effects: EffectRow,
210    /// Node signing key for creation attestations (Track F Slice A).
211    /// Read from the environment at construction — the mesh identity is
212    /// process-stable by design, so no re-read is needed per dispatch.
213    attestation_key: Option<String>,
214}
215
216impl MemoryCreateTool {
217    pub fn new(
218        store: Arc<MemoryStore>,
219        search: Option<Arc<SearchEngine>>,
220        recall: Option<Arc<RecallEngine>>,
221    ) -> Self {
222        Self {
223            store,
224            search,
225            recall,
226            stats: ToolStats::default(),
227            effects: EffectRow {
228                // Writes whichever galaxy the caller selects at runtime.
229                // Citta is excluded: a fresh write into the consciousness
230                // stream is refused by the pipeline's runtime Satya check.
231                writes: fresh_write_galaxies(),
232                invokes: vec![Capability::MemoryWrite],
233                ..Default::default()
234            },
235            attestation_key: node_attestation_key(),
236        }
237    }
238
239    /// Explicit attestation key (tests; the `with_armed` seam — production
240    /// uses [`Self::new`]'s env read).
241    #[must_use]
242    pub fn with_attestation_key(
243        store: Arc<MemoryStore>,
244        search: Option<Arc<SearchEngine>>,
245        recall: Option<Arc<RecallEngine>>,
246        attestation_key: Option<String>,
247    ) -> Self {
248        let mut tool = Self::new(store, search, recall);
249        tool.attestation_key = attestation_key;
250        tool
251    }
252}
253
254#[async_trait]
255impl Tool for MemoryCreateTool {
256    fn name(&self) -> &str {
257        "memory.create"
258    }
259    fn gana(&self) -> Gana {
260        Gana::Encampment
261    }
262    fn effects(&self) -> &EffectRow {
263        &self.effects
264    }
265    fn input_schema(&self) -> Value {
266        schema(
267            &json!({
268                "content": str_prop("Memory content (text)"),
269                "galaxy": str_prop("Target galaxy (default codex)"),
270                "tags": str_array_prop("Optional tags"),
271                "title": str_prop("Optional human-readable title (envelope v2)"),
272                "topic": str_prop("Optional topic label for subject-scoped retrieval (envelope v2)"),
273                "importance": str_prop("Optional importance 0.0-1.0 (write gate applies class ceilings/floors when the class is recognized)"),
274                "source": str_prop("Authorship claim: user (user-dictated content, trust 1.0) | agent (default, trust 0.7) | other free-form class (trust 0.7)"),
275            }),
276            &["content"],
277        )
278    }
279    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
280        let content = args
281            .get("content")
282            .and_then(|v| v.as_str())
283            .ok_or_else(|| wm_core::CoreError::InvalidArgs("content (string) required".into()))?;
284        let galaxy_str = args
285            .get("galaxy")
286            .and_then(|v| v.as_str())
287            .unwrap_or("codex");
288        let galaxy = parse_galaxy(galaxy_str)?;
289        let tags: Vec<String> = args
290            .get("tags")
291            .and_then(|v| v.as_array())
292            .map(|a| {
293                a.iter()
294                    .filter_map(|v| v.as_str().map(String::from))
295                    .collect()
296            })
297            .unwrap_or_default();
298
299        if let Some(search) = &self.search {
300            if search.is_readonly() {
301                return Err(wm_core::CoreError::InvalidArgs(
302                    "read-only mode: memory.create disabled (another process owns the index)"
303                        .into(),
304                ));
305            }
306        }
307        // Phase 3 secrets hygiene: credential-shaped content is flagged at
308        // the boundary (warn + advise keyring; the write proceeds so the
309        // agent sees the warning and can act rather than hide the secret).
310        let kinds = wm_memory::credential_shaped_content(content);
311        let warnings: Vec<String> = kinds
312            .iter()
313            .map(|k| {
314                format!(
315                    "content looks like a credential ({k}) — {}",
316                    wm_memory::CREDENTIAL_ADVICE
317                )
318            })
319            .collect();
320        let mut memory = Memory::new(galaxy, content.to_string());
321        memory.metadata.tags = tags;
322        // Envelope v2 (S4): optional title/topic ride the metadata and
323        // survive export/import roundtrips.
324        memory.metadata.title = args
325            .get("title")
326            .and_then(Value::as_str)
327            .map(str::trim)
328            .filter(|s| !s.is_empty())
329            .map(String::from);
330        memory.metadata.topic = args
331            .get("topic")
332            .and_then(Value::as_str)
333            .map(str::trim)
334            .filter(|s| !s.is_empty())
335            .map(String::from);
336        // V8 S5: optional importance (the write gate rewrites this to the
337        // class-policy value when it recognizes the content); class/tier
338        // re-stamped now that tags are known.
339        if let Some(importance) = args.get("importance").and_then(Value::as_f64) {
340            memory.metadata.importance = importance as f32;
341        }
342        memory.metadata.class = wm_memory::typology::detect_class(content, &memory.metadata.tags);
343        memory.metadata.tier = memory.metadata.class.map_or(
344            wm_memory::memory::Tier::Working,
345            wm_memory::typology::initial_tier,
346        );
347        // Provenance stamp: the caller claims authorship explicitly.
348        // Default is agent-authored (the tool is called by agents); a
349        // "user" claim must be passed deliberately — user-dictated content.
350        // Trust is DERIVED from the claimed class, never caller-chosen:
351        // user 1.0, anything else 0.7 (tool-ingested neutral).
352        let claimed_source = args
353            .get("source")
354            .and_then(Value::as_str)
355            .map(str::trim)
356            .filter(|s| !s.is_empty());
357        let (source, trust) = match claimed_source {
358            Some("user") => ("user", 1.0),
359            Some(other) => (other, 0.7),
360            None => ("agent", 0.7),
361        };
362        memory.metadata.source = source.to_string();
363        memory.metadata.source_trust = trust;
364        let id = memory.metadata.id;
365
366        // If RecallEngine with a real embedder is available, use it for
367        // auto-embedding + Tantivy indexing in one shot.
368        if let Some(recall) = &self.recall {
369            if let Err(e) = recall.store_with_embedding(galaxy, &memory) {
370                tracing::warn!("RecallEngine store_with_embedding failed for memory {id}: {e}");
371                // Fall back to plain store + Tantivy
372                self.store.put(galaxy, &memory)?;
373                if let Some(search) = &self.search {
374                    if let Err(e) = (|| {
375                        let mut writer = search.writer()?;
376                        search.add_document(
377                            &mut writer,
378                            &id.to_string(),
379                            galaxy.db_name(),
380                            content,
381                            &memory.metadata.tags,
382                            memory.metadata.created_at.timestamp(),
383                        )?;
384                        search.commit(&mut writer)?;
385                        Ok::<(), wm_core::CoreError>(())
386                    })() {
387                        tracing::warn!("Tantivy indexing failed for memory {id}: {e}");
388                    }
389                }
390            }
391        } else {
392            self.store.put(galaxy, &memory)?;
393            // Index into Tantivy if search engine is available (non-fatal)
394            if let Some(search) = &self.search {
395                if let Err(e) = (|| {
396                    let mut writer = search.writer()?;
397                    search.add_document(
398                        &mut writer,
399                        &id.to_string(),
400                        galaxy.db_name(),
401                        content,
402                        &memory.metadata.tags,
403                        memory.metadata.created_at.timestamp(),
404                    )?;
405                    search.commit(&mut writer)?;
406                    Ok::<(), wm_core::CoreError>(())
407                })() {
408                    tracing::warn!("Tantivy indexing failed for memory {id}: {e}");
409                }
410            }
411        }
412
413        capture_explicit_memory(
414            &self.store,
415            &memory,
416            EpisodicKind::Observation,
417            // Episodic provenance follows the same claim: agent default,
418            // User only when deliberately claimed.
419            if source == "user" {
420                ProvenanceSource::User
421            } else {
422                ProvenanceSource::Agent
423            },
424            ctx.session_id,
425            0,
426        );
427
428        // Track F Slice A (D5): attest the create when a node key is
429        // available. Evidence, not a gate — attestation outcome never
430        // fails the create (see helper docs).
431        let (attested, attested_reason) = attest_created_memory(
432            &self.store,
433            galaxy,
434            id,
435            &memory.metadata.content_hash,
436            ctx,
437            self.attestation_key.as_deref(),
438        );
439
440        let mut response = json!({
441            "status": "success",
442            "id": id.to_string(),
443            "galaxy": galaxy.db_name(),
444            "content_hash": memory.metadata.content_hash,
445            "source": source,
446            "source_trust": trust,
447            "attested": attested,
448        });
449        if let Some(reason) = attested_reason {
450            response["attested_reason"] = json!(reason);
451        }
452        if !warnings.is_empty() {
453            response["warnings"] = json!(warnings);
454        }
455        Ok(response)
456    }
457    fn stats(&self) -> &ToolStats {
458        &self.stats
459    }
460}
461
462// ── Tool: memory.batch_create ───────────────────────────────────────
463
464/// Batch-create multiple memories with a single Tantivy commit.
465///
466/// Accepts an `items` array of `{content, galaxy?, tags?}` objects.
467/// All documents are added to the Tantivy index in one commit, making
468/// bulk ingestion ~10-50x faster than individual `memory.create` calls.
469pub struct MemoryBatchCreateTool {
470    store: Arc<MemoryStore>,
471    search: Option<Arc<SearchEngine>>,
472    recall: Option<Arc<RecallEngine>>,
473    stats: ToolStats,
474    effects: EffectRow,
475    /// Node signing key for creation attestations (Track F Slice A) —
476    /// same env-at-construction rule as [`MemoryCreateTool`].
477    attestation_key: Option<String>,
478}
479
480impl MemoryBatchCreateTool {
481    pub fn new(
482        store: Arc<MemoryStore>,
483        search: Option<Arc<SearchEngine>>,
484        recall: Option<Arc<RecallEngine>>,
485    ) -> Self {
486        Self {
487            store,
488            search,
489            recall,
490            stats: ToolStats::default(),
491            effects: EffectRow {
492                writes: fresh_write_galaxies(),
493                invokes: vec![Capability::MemoryWrite],
494                ..Default::default()
495            },
496            attestation_key: node_attestation_key(),
497        }
498    }
499
500    /// Explicit attestation key (tests; the `with_armed` seam).
501    #[must_use]
502    pub fn with_attestation_key(
503        store: Arc<MemoryStore>,
504        search: Option<Arc<SearchEngine>>,
505        recall: Option<Arc<RecallEngine>>,
506        attestation_key: Option<String>,
507    ) -> Self {
508        let mut tool = Self::new(store, search, recall);
509        tool.attestation_key = attestation_key;
510        tool
511    }
512}
513
514#[async_trait]
515impl Tool for MemoryBatchCreateTool {
516    fn name(&self) -> &str {
517        "memory.batch_create"
518    }
519    fn gana(&self) -> Gana {
520        Gana::Encampment
521    }
522    fn effects(&self) -> &EffectRow {
523        &self.effects
524    }
525    fn input_schema(&self) -> Value {
526        schema(
527            &json!({
528                "items": {
529                    "type": "array",
530                    "description": "Array of {content, galaxy?, tags?} objects",
531                    "items": {
532                        "type": "object",
533                        "properties": {
534                            "content": str_prop("Memory content (text)"),
535                            "galaxy": str_prop("Target galaxy (default codex)"),
536                            "tags": str_array_prop("Optional tags"),
537                            "importance": str_prop("Optional importance 0.0-1.0 (write gate applies class ceilings/floors when the class is recognized)"),
538                        },
539                        "required": ["content"],
540                    },
541                },
542            }),
543            &["items"],
544        )
545    }
546    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
547        let items = args
548            .get("items")
549            .and_then(|v| v.as_array())
550            .ok_or_else(|| wm_core::CoreError::InvalidArgs("items (array) required".into()))?;
551
552        if let Some(search) = &self.search {
553            if search.is_readonly() {
554                return Err(wm_core::CoreError::InvalidArgs(
555                    "read-only mode: memory.batch_create disabled (another process owns the index)"
556                        .into(),
557                ));
558            }
559        }
560
561        let mut ids: Vec<String> = Vec::new();
562        // Episodic-capture provenance: User only when EVERY item
563        // deliberately claimed user (the create-tool rule, derived — the
564        // old unconditional User stamp is the 68547b9 miss the build plan
565        // flags).
566        let mut all_items_user_claimed = true;
567        // Phase 3 secrets hygiene: aggregate credential-shape kinds across
568        // the batch and surface one warning block in the response.
569        let mut cred_kinds: Vec<&'static str> = Vec::new();
570        // Only acquire a Tantivy writer when we don't have a RecallEngine.
571        // RecallEngine::store_batch_with_embedding manages its own writer,
572        // and Tantivy only allows one writer at a time.
573        let mut writer_guard = if self.recall.is_none() {
574            if let Some(search) = &self.search {
575                Some(search.writer()?)
576            } else {
577                None
578            }
579        } else {
580            None
581        };
582
583        // Collect memories for batch processing
584        let mut memories: Vec<(Galaxy, Memory)> = Vec::new();
585
586        for item in items {
587            let content = item
588                .get("content")
589                .and_then(|v| v.as_str())
590                .ok_or_else(|| {
591                    wm_core::CoreError::InvalidArgs("each item needs content (string)".into())
592                })?;
593            let galaxy_str = item
594                .get("galaxy")
595                .and_then(|v| v.as_str())
596                .unwrap_or("codex");
597            let galaxy = parse_galaxy(galaxy_str)?;
598            let tags: Vec<String> = item
599                .get("tags")
600                .and_then(|v| v.as_array())
601                .map(|a| {
602                    a.iter()
603                        .filter_map(|v| v.as_str().map(String::from))
604                        .collect()
605                })
606                .unwrap_or_default();
607
608            let mut memory = Memory::new(galaxy, content.to_string());
609            memory.metadata.tags = tags;
610            // V8 S5: optional importance (the write gate rewrites this to
611            // the class policy value when it recognizes the content);
612            // class/tier re-stamped with tags now that they are known —
613            // tag families (rsi:/ingest:/heritage) carry provenance the
614            // content shape alone lacks.
615            if let Some(importance) = item.get("importance").and_then(Value::as_f64) {
616                memory.metadata.importance = importance as f32;
617            }
618            memory.metadata.class =
619                wm_memory::typology::detect_class(content, &memory.metadata.tags);
620            memory.metadata.tier = memory.metadata.class.map_or(
621                wm_memory::memory::Tier::Working,
622                wm_memory::typology::initial_tier,
623            );
624            // Same provenance rule as memory.create: agent-authored by
625            // default; a "user" claim must be deliberate. Trust derives
626            // from the claimed class (user 1.0, otherwise 0.7).
627            let claimed_source = item
628                .get("source")
629                .and_then(Value::as_str)
630                .map(str::trim)
631                .filter(|s| !s.is_empty());
632            let (source, trust) = match claimed_source {
633                Some("user") => ("user", 1.0),
634                Some(other) => (other, 0.7),
635                None => ("agent", 0.7),
636            };
637            if source != "user" {
638                all_items_user_claimed = false;
639            }
640            memory.metadata.source = source.to_string();
641            memory.metadata.source_trust = trust;
642            let id = memory.metadata.id;
643            ids.push(id.to_string());
644            for k in wm_memory::credential_shaped_content(content) {
645                if !cred_kinds.contains(&k) {
646                    cred_kinds.push(k);
647                }
648            }
649            memories.push((galaxy, memory));
650        }
651
652        // If RecallEngine with a real embedder is available, batch-embed + single commit.
653        if let Some(recall) = &self.recall {
654            let entries: Vec<(Galaxy, &Memory)> = memories.iter().map(|(g, m)| (*g, m)).collect();
655            match recall.store_batch_with_embedding(&entries) {
656                Ok(n) => {
657                    tracing::info!("batch_create: embedded {n} memories in single batch");
658                }
659                Err(e) => {
660                    tracing::warn!(
661                        "batch_create: store_batch_with_embedding failed ({e}), falling back to per-item"
662                    );
663                    // Fall back to per-item store + Tantivy batch index.
664                    // Acquire writer lazily since writer_guard is None when
665                    // recall is Some (to avoid Tantivy lock conflict).
666                    let mut fallback_writer = if writer_guard.is_none() {
667                        if let Some(search) = &self.search {
668                            search.writer().ok()
669                        } else {
670                            None
671                        }
672                    } else {
673                        None
674                    };
675                    for (galaxy, memory) in &memories {
676                        self.store.put(*galaxy, memory)?;
677                        let writer_slot = writer_guard.as_mut().or(fallback_writer.as_mut());
678                        if let Some(guard) = writer_slot {
679                            if let Some(search) = &self.search {
680                                if let Err(e) = search.add_document(
681                                    guard,
682                                    &memory.metadata.id.to_string(),
683                                    galaxy.db_name(),
684                                    &memory.content,
685                                    &memory.metadata.tags,
686                                    memory.metadata.created_at.timestamp(),
687                                ) {
688                                    tracing::warn!(
689                                        "Tantivy indexing failed for memory {}: {e}",
690                                        memory.metadata.id
691                                    );
692                                }
693                            }
694                        }
695                    }
696                    // Commit the fallback writer if we created one
697                    if let Some(mut guard) = fallback_writer {
698                        if let Some(search) = &self.search {
699                            if let Err(e) = search.commit(&mut guard) {
700                                tracing::warn!("Tantivy fallback commit failed: {e}");
701                            }
702                        }
703                    }
704                }
705            }
706        } else {
707            // No embedder: store to LMDB + batch-index in Tantivy
708            for (galaxy, memory) in &memories {
709                self.store.put(*galaxy, memory)?;
710                if let Some(ref mut guard) = writer_guard {
711                    if let Some(search) = &self.search {
712                        if let Err(e) = search.add_document(
713                            &mut *guard,
714                            &memory.metadata.id.to_string(),
715                            galaxy.db_name(),
716                            &memory.content,
717                            &memory.metadata.tags,
718                            memory.metadata.created_at.timestamp(),
719                        ) {
720                            tracing::warn!(
721                                "Tantivy indexing failed for memory {}: {e}",
722                                memory.metadata.id
723                            );
724                        }
725                    }
726                }
727            }
728        }
729
730        // Single commit for all documents
731        if let Some(ref mut guard) = writer_guard {
732            if let Some(search) = &self.search {
733                if let Err(e) = search.commit(&mut *guard) {
734                    tracing::warn!("Tantivy batch commit failed: {e}");
735                }
736            }
737        }
738
739        capture_explicit_memories(
740            &self.store,
741            &memories,
742            EpisodicKind::Observation,
743            // Derived, not unconditional: agent default, User only when
744            // every item deliberately claimed user (mirrors memory.create).
745            if all_items_user_claimed {
746                ProvenanceSource::User
747            } else {
748                ProvenanceSource::Agent
749            },
750            ctx.session_id,
751        );
752
753        // Track F Slice A (D5): same attestation class as memory.create —
754        // one signed record per created memory, evidence never a gate.
755        let mut attested_count = 0usize;
756        for (galaxy, memory) in &memories {
757            let (ok, _) = attest_created_memory(
758                &self.store,
759                *galaxy,
760                memory.metadata.id,
761                &memory.metadata.content_hash,
762                ctx,
763                self.attestation_key.as_deref(),
764            );
765            attested_count += usize::from(ok);
766        }
767
768        let mut response = json!({
769            "status": "success",
770            "count": ids.len(),
771            "ids": ids,
772            "attested_count": attested_count,
773        });
774        if !cred_kinds.is_empty() {
775            response["warnings"] = json!(
776                cred_kinds
777                    .iter()
778                    .map(|k| format!(
779                        "some items look like credentials ({k}) — {}",
780                        wm_memory::CREDENTIAL_ADVICE
781                    ))
782                    .collect::<Vec<String>>()
783            );
784        }
785        Ok(response)
786    }
787    fn stats(&self) -> &ToolStats {
788        &self.stats
789    }
790}
791
792// ── Tool: memory.read ────────────────────────────────────────────────
793
794/// Read a memory by ID from a galaxy.
795pub struct MemoryReadTool {
796    store: Arc<MemoryStore>,
797    stats: ToolStats,
798    effects: EffectRow,
799}
800
801impl MemoryReadTool {
802    pub fn new(store: Arc<MemoryStore>) -> Self {
803        Self {
804            store,
805            stats: ToolStats::default(),
806            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
807        }
808    }
809}
810
811#[async_trait]
812impl Tool for MemoryReadTool {
813    fn name(&self) -> &str {
814        "memory.read"
815    }
816    fn gana(&self) -> Gana {
817        Gana::WinnowingBasket
818    }
819    fn effects(&self) -> &EffectRow {
820        &self.effects
821    }
822    fn input_schema(&self) -> Value {
823        schema(
824            &json!({
825                "id": str_prop("Memory UUID"),
826                "galaxy": str_prop("Galaxy containing the memory (default codex)"),
827            }),
828            &["id"],
829        )
830    }
831    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
832        let id_str = args
833            .get("id")
834            .and_then(|v| v.as_str())
835            .ok_or_else(|| wm_core::CoreError::InvalidArgs("id (string) required".into()))?;
836        let id = uuid::Uuid::parse_str(id_str)
837            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid UUID: {e}")))?;
838        let galaxy_str = args
839            .get("galaxy")
840            .and_then(|v| v.as_str())
841            .unwrap_or("codex");
842        let galaxy = parse_galaxy(galaxy_str)?;
843
844        match self.store.get(galaxy, id)? {
845            Some(memory) => {
846                if memory.metadata.is_private {
847                    // Private memories never appear in MCP responses —
848                    // treat them as not found rather than leaking content.
849                    return Ok(json!({
850                        "status": "not_found",
851                        "id": id_str,
852                        "galaxy": galaxy.db_name(),
853                    }));
854                }
855                Ok(json!({
856                    "status": "success",
857                    "id": memory.metadata.id.to_string(),
858                    "galaxy": memory.metadata.galaxy.db_name(),
859                    "content": memory.content,
860                    "tags": memory.metadata.tags,
861                    "created_at": memory.metadata.created_at.to_rfc3339(),
862                }))
863            }
864            None => Ok(json!({
865                "status": "not_found",
866                "id": id_str,
867                "galaxy": galaxy.db_name(),
868            })),
869        }
870    }
871    fn stats(&self) -> &ToolStats {
872        &self.stats
873    }
874}
875
876// ── Tool: memory.list ────────────────────────────────────────────────
877
878/// List memories from a galaxy (up to limit).
879pub struct MemoryListTool {
880    store: Arc<MemoryStore>,
881    stats: ToolStats,
882    effects: EffectRow,
883}
884
885impl MemoryListTool {
886    pub fn new(store: Arc<MemoryStore>) -> Self {
887        Self {
888            store,
889            stats: ToolStats::default(),
890            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
891        }
892    }
893}
894
895#[async_trait]
896impl Tool for MemoryListTool {
897    fn name(&self) -> &str {
898        "memory.list"
899    }
900    fn gana(&self) -> Gana {
901        Gana::WinnowingBasket
902    }
903    fn effects(&self) -> &EffectRow {
904        &self.effects
905    }
906    fn input_schema(&self) -> Value {
907        schema(
908            &json!({
909                "galaxy": str_prop("Galaxy to list (default codex)"),
910                "limit": int_prop("Maximum entries (default 20)"),
911                "offset": int_prop("Skip this many matching entries before returning (default 0)"),
912                "exclude_tags": {
913                    "type": "array",
914                    "items": {"type": "string"},
915                    "description": "Drop memories carrying any of these tags",
916                },
917            }),
918            &[],
919        )
920    }
921    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
922        let galaxy_str = args
923            .get("galaxy")
924            .and_then(|v| v.as_str())
925            .unwrap_or("codex");
926        let limit = args
927            .get("limit")
928            .and_then(serde_json::Value::as_u64)
929            .unwrap_or(20) as usize;
930        let offset = args
931            .get("offset")
932            .and_then(serde_json::Value::as_u64)
933            .unwrap_or(0) as usize;
934        let exclude_tags: Vec<String> = args
935            .get("exclude_tags")
936            .and_then(|v| v.as_array())
937            .map(|arr| {
938                arr.iter()
939                    .filter_map(|t| t.as_str().map(String::from))
940                    .collect()
941            })
942            .unwrap_or_default();
943        let galaxy = parse_galaxy(galaxy_str)?;
944
945        // Scan wide, then filter, then page: offset/limit apply to the
946        // VISIBLE surface (private memories and excluded tags never
947        // consume page slots).
948        let memories = self.store.scan(galaxy, 10_000)?;
949        let total = self.store.count(galaxy)?;
950
951        let visible: Vec<&wm_memory::Memory> = memories
952            .iter()
953            .filter(|m| crate::expansion::common::mcp_visible(m))
954            .filter(|m| crate::expansion::common::validity_visible(m))
955            .filter(|m| {
956                !exclude_tags
957                    .iter()
958                    .any(|t| m.metadata.tags.iter().any(|mt| mt == t))
959            })
960            .collect();
961        let entries: Vec<Value> = visible
962            .iter()
963            .skip(offset)
964            .take(limit)
965            .map(|m| {
966                json!({
967                    "id": m.metadata.id.to_string(),
968                    "content_preview": m.content.chars().take(80).collect::<String>(),
969                    "tags": m.metadata.tags,
970                    "created_at": m.metadata.created_at.to_rfc3339(),
971                })
972            })
973            .collect();
974
975        Ok(json!({
976            "status": "success",
977            "galaxy": galaxy.db_name(),
978            "total": total,
979            "matched": visible.len(),
980            "offset": offset,
981            "returned": entries.len(),
982            "memories": entries,
983        }))
984    }
985    fn stats(&self) -> &ToolStats {
986        &self.stats
987    }
988}
989
990// ── Tool: gnosis ─────────────────────────────────────────────────────
991
992/// System introspection — returns basic system state.
993pub struct GnosisTool {
994    store: Arc<MemoryStore>,
995    tool_count: usize,
996    stats: ToolStats,
997    effects: EffectRow,
998}
999
1000impl GnosisTool {
1001    pub fn new(store: Arc<MemoryStore>) -> Self {
1002        Self {
1003            store,
1004            tool_count: 0,
1005            stats: ToolStats::default(),
1006            effects: EffectRow::read_only(vec![Resource::Galaxy("substrate".into())]),
1007        }
1008    }
1009
1010    /// Create with a known tool count (computed at registration time).
1011    pub fn with_tool_count(store: Arc<MemoryStore>, tool_count: usize) -> Self {
1012        Self {
1013            store,
1014            tool_count,
1015            stats: ToolStats::default(),
1016            effects: EffectRow::read_only(vec![Resource::Galaxy("substrate".into())]),
1017        }
1018    }
1019}
1020
1021#[async_trait]
1022impl Tool for GnosisTool {
1023    fn name(&self) -> &str {
1024        "gnosis"
1025    }
1026    fn gana(&self) -> Gana {
1027        Gana::Root
1028    }
1029    fn effects(&self) -> &EffectRow {
1030        &self.effects
1031    }
1032    async fn call(&self, ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
1033        let mut galaxy_stats = serde_json::Map::new();
1034        for galaxy in Galaxy::all() {
1035            let count = self.store.count(galaxy).unwrap_or(0);
1036            if count > 0 {
1037                galaxy_stats.insert(galaxy.db_name().to_string(), json!(count));
1038            }
1039        }
1040
1041        Ok(json!({
1042            "status": "success",
1043            "version": env!("CARGO_PKG_VERSION"),
1044            "store_path": self.store.path().display().to_string(),
1045            "brain_wave": format!("{:?}", ctx.brain_wave),
1046            "available_tools": self.tool_count,
1047            "galaxies_with_data": galaxy_stats.len(),
1048            "galaxy_counts": galaxy_stats,
1049            "ganas": Gana::COUNT,
1050            "galaxies": Galaxy::COUNT,
1051        }))
1052    }
1053    fn stats(&self) -> &ToolStats {
1054        &self.stats
1055    }
1056}
1057
1058// ── Tool: tools.list ─────────────────────────────────────────────────
1059
1060/// List all registered tools.
1061pub struct ToolsListTool {
1062    registry: Arc<ToolRegistry>,
1063    stats: ToolStats,
1064    effects: EffectRow,
1065}
1066
1067impl ToolsListTool {
1068    #[must_use]
1069    pub fn new(registry: Arc<ToolRegistry>) -> Self {
1070        Self {
1071            registry,
1072            stats: ToolStats::default(),
1073            effects: EffectRow::pure(),
1074        }
1075    }
1076}
1077
1078#[async_trait]
1079impl Tool for ToolsListTool {
1080    fn name(&self) -> &str {
1081        "tools.list"
1082    }
1083    fn gana(&self) -> Gana {
1084        Gana::Ghost
1085    }
1086    fn effects(&self) -> &EffectRow {
1087        &self.effects
1088    }
1089    async fn call(&self, ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
1090        let available = self.registry.available_in(ctx.brain_wave);
1091        let tools: Vec<Value> = available
1092            .iter()
1093            .map(|t| {
1094                // MCP tool annotations derived from the declared effects —
1095                // clients and registries use these for safety decisions.
1096                let effects = t.effects();
1097                json!({
1098                    "name": t.name(),
1099                    "gana": format!("{:?}", t.gana()),
1100                    "description": t.description(),
1101                    "input_schema": t.input_schema(),
1102                    "annotations": {
1103                        "readOnlyHint": effects.writes.is_empty(),
1104                        "destructiveHint": effects.destructive,
1105                    },
1106                })
1107            })
1108            .collect();
1109        Ok(json!({
1110            "status": "success",
1111            "brain_wave": format!("{:?}", ctx.brain_wave),
1112            "total": tools.len(),
1113            "tools": tools,
1114        }))
1115    }
1116    fn stats(&self) -> &ToolStats {
1117        &self.stats
1118    }
1119}
1120
1121// ── Tool: memory.delete ──────────────────────────────────────────────
1122
1123/// Delete a memory by ID.
1124///
1125/// With an explicit `galaxy` argument, only that galaxy is touched. Without
1126/// one, the ID is resolved across all memory galaxies (so a memory created in
1127/// e.g. `sessions` is not reported "not_found" just because the default
1128/// galaxy was `codex`). Destructive; requires `confirm: true`.
1129///
1130/// If a `SearchEngine` is provided, the document is also removed from the
1131/// Tantivy index after the LMDB delete.
1132pub struct MemoryDeleteTool {
1133    store: Arc<MemoryStore>,
1134    search: Option<Arc<SearchEngine>>,
1135    stats: ToolStats,
1136    effects: EffectRow,
1137}
1138
1139impl MemoryDeleteTool {
1140    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
1141        Self {
1142            store,
1143            search,
1144            stats: ToolStats::default(),
1145            effects: EffectRow {
1146                // Delete reads the record it removes (index cleanup), so the
1147                // read-modify-write declaration covers the runtime galaxy.
1148                writes: memory_galaxy_writes(),
1149                reads: memory_galaxy_reads(),
1150                invokes: vec![Capability::MemoryWrite],
1151                destructive: true,
1152                ..Default::default()
1153            },
1154        }
1155    }
1156}
1157
1158#[async_trait]
1159impl Tool for MemoryDeleteTool {
1160    fn name(&self) -> &str {
1161        "memory.delete"
1162    }
1163    fn gana(&self) -> Gana {
1164        Gana::Encampment
1165    }
1166    fn effects(&self) -> &EffectRow {
1167        &self.effects
1168    }
1169    fn input_schema(&self) -> Value {
1170        schema(
1171            &json!({
1172                "id": str_prop("Memory UUID"),
1173                "galaxy": str_prop("Galaxy containing the memory (optional; when omitted the id is resolved across all memory galaxies)"),
1174                "confirm": bool_prop("Required — memory.delete is destructive"),
1175            }),
1176            &["id", "confirm"],
1177        )
1178    }
1179    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1180        let id_str = args
1181            .get("id")
1182            .and_then(|v| v.as_str())
1183            .ok_or_else(|| wm_core::CoreError::InvalidArgs("id (string) required".into()))?;
1184        let id = uuid::Uuid::parse_str(id_str)
1185            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid UUID: {e}")))?;
1186
1187        if let Some(search) = &self.search {
1188            if search.is_readonly() {
1189                return Err(wm_core::CoreError::InvalidArgs(
1190                    "read-only mode: memory.delete disabled (another process owns the index)"
1191                        .into(),
1192                ));
1193            }
1194        }
1195
1196        let targets: Vec<Galaxy> = match args.get("galaxy").and_then(|v| v.as_str()) {
1197            Some(g) => vec![parse_galaxy(g)?],
1198            None => Galaxy::memory_galaxies().to_vec(),
1199        };
1200
1201        let mut deleted_from: Vec<&str> = Vec::new();
1202        for galaxy in targets {
1203            if self.store.delete(galaxy, id)? {
1204                deleted_from.push(galaxy.db_name());
1205            }
1206        }
1207
1208        // Remove from Tantivy index if search engine is available (non-fatal)
1209        if !deleted_from.is_empty() {
1210            if let Some(search) = &self.search {
1211                if let Err(e) = (|| {
1212                    let mut writer = search.writer()?;
1213                    search.delete_document(&mut writer, id_str)?;
1214                    search.commit(&mut writer)?;
1215                    Ok::<(), wm_core::CoreError>(())
1216                })() {
1217                    tracing::warn!("Tantivy de-indexing failed for memory {id_str}: {e}");
1218                }
1219            }
1220        }
1221
1222        if deleted_from.is_empty() {
1223            return Ok(json!({
1224                "status": "not_found",
1225                "id": id_str,
1226                "hint": "id not found in any memory galaxy; pass an explicit galaxy to target one"
1227            }));
1228        }
1229
1230        let mut body = serde_json::Map::new();
1231        body.insert("status".into(), json!("success"));
1232        body.insert("id".into(), json!(id_str));
1233        if args.get("galaxy").and_then(|v| v.as_str()).is_some() {
1234            body.insert("galaxy".into(), json!(deleted_from[0]));
1235        }
1236        body.insert(
1237            "galaxies".into(),
1238            json!(deleted_from.iter().map(|g| json!(g)).collect::<Vec<_>>()),
1239        );
1240        body.insert("deleted".into(), json!(deleted_from.len()));
1241        Ok(Value::Object(body))
1242    }
1243    fn stats(&self) -> &ToolStats {
1244        &self.stats
1245    }
1246}
1247
1248/// `memory.batch_delete` — bulk deletion by explicit id list.
1249///
1250/// One governed dispatch for maintenance-scale runs (heritage dedupe,
1251/// telemetry sweeps): a single round trip, one Tantivy commit for the whole
1252/// batch, one karma/audit entry. Destructive: requires `confirm: true` and an
1253/// explicit id list (capped) — the bulk-delete confirm gate from the
1254/// incident-ledger lessons; there is deliberately no query-form variant.
1255pub struct MemoryBatchDeleteTool {
1256    store: Arc<MemoryStore>,
1257    search: Option<Arc<SearchEngine>>,
1258    stats: ToolStats,
1259    effects: EffectRow,
1260}
1261
1262impl MemoryBatchDeleteTool {
1263    pub fn new(store: Arc<MemoryStore>, search: Option<Arc<SearchEngine>>) -> Self {
1264        Self {
1265            store,
1266            search,
1267            stats: ToolStats::default(),
1268            effects: EffectRow {
1269                writes: memory_galaxy_writes(),
1270                reads: memory_galaxy_reads(),
1271                invokes: vec![Capability::MemoryWrite],
1272                destructive: true,
1273                ..Default::default()
1274            },
1275        }
1276    }
1277}
1278
1279#[async_trait]
1280impl Tool for MemoryBatchDeleteTool {
1281    fn name(&self) -> &str {
1282        "memory.batch_delete"
1283    }
1284    fn gana(&self) -> Gana {
1285        Gana::Encampment
1286    }
1287    fn effects(&self) -> &EffectRow {
1288        &self.effects
1289    }
1290    fn input_schema(&self) -> Value {
1291        schema(
1292            &json!({
1293                "ids": {"type": "array", "items": {"type": "string"},
1294                        "description": "Memory UUIDs to delete (max 200000)"},
1295                "confirm": bool_prop("Required — memory.batch_delete is destructive"),
1296            }),
1297            &["ids", "confirm"],
1298        )
1299    }
1300    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1301        const MAX_IDS: usize = 200_000;
1302        if !args
1303            .get("confirm")
1304            .and_then(serde_json::Value::as_bool)
1305            .unwrap_or(false)
1306        {
1307            return Err(wm_core::CoreError::InvalidArgs(
1308                "confirm (bool) required — memory.batch_delete is destructive".into(),
1309            ));
1310        }
1311        let ids: Vec<String> = args
1312            .get("ids")
1313            .and_then(|v| v.as_array())
1314            .map(|a| {
1315                a.iter()
1316                    .filter_map(|v| v.as_str().map(String::from))
1317                    .collect()
1318            })
1319            .ok_or_else(|| {
1320                wm_core::CoreError::InvalidArgs("ids (array of UUID strings) required".into())
1321            })?;
1322        if ids.is_empty() {
1323            return Ok(json!({"status": "success", "requested": 0, "deleted": 0, "not_found": 0}));
1324        }
1325        if ids.len() > MAX_IDS {
1326            return Err(wm_core::CoreError::InvalidArgs(format!(
1327                "ids capped at {MAX_IDS}; split the batch"
1328            )));
1329        }
1330
1331        if let Some(search) = &self.search {
1332            if search.is_readonly() {
1333                return Err(wm_core::CoreError::InvalidArgs(
1334                    "read-only mode: memory.batch_delete disabled (another process owns the index)"
1335                        .into(),
1336                ));
1337            }
1338        }
1339
1340        let targets: Vec<Galaxy> = Galaxy::memory_galaxies().to_vec();
1341        let mut deleted_ids: Vec<(String, Vec<&str>)> = Vec::new();
1342        let mut not_found: usize = 0;
1343        for id_str in &ids {
1344            let Ok(id) = uuid::Uuid::parse_str(id_str) else {
1345                not_found += 1;
1346                continue;
1347            };
1348            let mut deleted_from: Vec<&str> = Vec::new();
1349            for galaxy in targets.iter().copied() {
1350                if self.store.delete(galaxy, id)? {
1351                    deleted_from.push(galaxy.db_name());
1352                }
1353            }
1354            if deleted_from.is_empty() {
1355                not_found += 1;
1356            } else {
1357                deleted_ids.push((id_str.clone(), deleted_from));
1358            }
1359        }
1360
1361        // Single Tantivy commit for the whole batch (non-fatal on failure).
1362        if !deleted_ids.is_empty() {
1363            if let Some(search) = &self.search {
1364                if let Err(e) = (|| {
1365                    let mut writer = search.writer()?;
1366                    for (id_str, _) in &deleted_ids {
1367                        search.delete_document(&mut writer, id_str)?;
1368                    }
1369                    search.commit(&mut writer)?;
1370                    Ok::<(), wm_core::CoreError>(())
1371                })() {
1372                    tracing::warn!(
1373                        "Tantivy batch de-indexing failed ({} ids): {e}",
1374                        deleted_ids.len()
1375                    );
1376                }
1377            }
1378        }
1379
1380        Ok(json!({
1381            "status": "success",
1382            "requested": ids.len(),
1383            "deleted": deleted_ids.len(),
1384            "not_found": not_found,
1385        }))
1386    }
1387    fn stats(&self) -> &ToolStats {
1388        &self.stats
1389    }
1390}
1391
1392// ── Tool: memory.query ───────────────────────────────────────────────
1393
1394/// Query memories with filters (tags, importance, temporal range).
1395pub struct MemoryQueryTool {
1396    store: Arc<MemoryStore>,
1397    stats: ToolStats,
1398    effects: EffectRow,
1399}
1400
1401impl MemoryQueryTool {
1402    pub fn new(store: Arc<MemoryStore>) -> Self {
1403        Self {
1404            store,
1405            stats: ToolStats::default(),
1406            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1407        }
1408    }
1409}
1410
1411#[async_trait]
1412impl Tool for MemoryQueryTool {
1413    fn name(&self) -> &str {
1414        "memory.query"
1415    }
1416    fn gana(&self) -> Gana {
1417        Gana::WinnowingBasket
1418    }
1419    fn effects(&self) -> &EffectRow {
1420        &self.effects
1421    }
1422    fn input_schema(&self) -> Value {
1423        schema(
1424            &json!({
1425                "query": str_prop("Case-insensitive substring filter over content (literal match). For tokenized, ranked full-text retrieval use memory.search"),
1426                "galaxy": str_prop("Galaxy to query (default codex)"),
1427                "tags": str_array_prop("Filter: memories with all of these tags"),
1428                "min_importance": num_prop("Filter: minimum importance (0-1)"),
1429                "max_importance": num_prop("Filter: maximum importance (0-1)"),
1430                "created_after": str_prop("Filter: only memories created at or after this RFC 3339 timestamp (e.g. 2026-08-01T00:00:00Z)"),
1431                "created_before": str_prop("Filter: only memories created at or before this RFC 3339 timestamp"),
1432                "limit": int_prop("Maximum entries (default 50)"),
1433            }),
1434            &[],
1435        )
1436    }
1437    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1438        let galaxy_str = args
1439            .get("galaxy")
1440            .and_then(|v| v.as_str())
1441            .unwrap_or("codex");
1442        let galaxy = parse_galaxy(galaxy_str)?;
1443        let limit = args
1444            .get("limit")
1445            .and_then(serde_json::Value::as_u64)
1446            .unwrap_or(50) as usize;
1447        let mut query = MemoryQuery::new().with_limit(limit);
1448        if let Some(text) = args
1449            .get("query")
1450            .and_then(serde_json::Value::as_str)
1451            .map(str::trim)
1452            .filter(|s| !s.is_empty())
1453        {
1454            query = query.with_content_substring(text);
1455        }
1456        if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
1457            let tag_list: Vec<String> = tags
1458                .iter()
1459                .filter_map(|v| v.as_str().map(String::from))
1460                .collect();
1461            if !tag_list.is_empty() {
1462                query = query.with_tags(tag_list);
1463            }
1464        }
1465        // Time-range passthrough — RFC 3339 bounds map onto the store's
1466        // temporal filter (previously accepted and silently ignored).
1467        let parse_bound = |name: &str| -> wm_core::Result<Option<chrono::DateTime<chrono::Utc>>> {
1468            match args.get(name).and_then(|v| v.as_str()) {
1469                Some(s) if !s.trim().is_empty() => chrono::DateTime::parse_from_rfc3339(s.trim())
1470                    .map(|t| Some(t.with_timezone(&chrono::Utc)))
1471                    .map_err(|_| {
1472                        wm_core::CoreError::InvalidArgs(format!(
1473                            "{name} must be an RFC 3339 timestamp (e.g. \"2026-08-01T00:00:00Z\"), got: {s}"
1474                        ))
1475                    }),
1476                _ => Ok(None),
1477            }
1478        };
1479        let created_after = parse_bound("created_after")?;
1480        let created_before = parse_bound("created_before")?;
1481        if let Some(after) = created_after {
1482            query = query.with_created_after(after);
1483        }
1484        if let Some(before) = created_before {
1485            query = query.with_created_before(before);
1486        }
1487
1488        let min_imp = args
1489            .get("min_importance")
1490            .and_then(serde_json::Value::as_f64);
1491        let max_imp = args
1492            .get("max_importance")
1493            .and_then(serde_json::Value::as_f64);
1494        if let (Some(min), Some(max)) = (min_imp, max_imp) {
1495            query = query.with_importance_range(min as f32, max as f32);
1496        } else if let Some(min) = min_imp {
1497            query = query.with_importance_range(min as f32, 1.0);
1498        }
1499
1500        let memories = self.store.query(galaxy, &query)?;
1501
1502        let entries: Vec<Value> = memories
1503            .iter()
1504            .filter(|m| crate::expansion::common::mcp_visible(m))
1505            .filter(|m| crate::expansion::common::validity_visible(m))
1506            .map(|m| {
1507                json!({
1508                    "id": m.metadata.id.to_string(),
1509                    "content_preview": m.content.chars().take(80).collect::<String>(),
1510                    "tags": m.metadata.tags,
1511                    "importance": m.metadata.importance,
1512                    "created_at": m.metadata.created_at.to_rfc3339(),
1513                })
1514            })
1515            .collect();
1516
1517        // `query` is a real case-insensitive substring filter over content
1518        // (the 2026-08-29 trap — text silently ignored, arbitrary page
1519        // returned — is fixed; MemoryQuery applies it galaxy-wide via the
1520        // matches() path). It is still a LITERAL match, not tokenized or
1521        // ranked — when text was passed, disclose that distinction so
1522        // agents know memory.search is the ranked verb.
1523        let query_applied = args
1524            .get("query")
1525            .and_then(|v| v.as_str())
1526            .is_some_and(|s| !s.trim().is_empty());
1527        let mut response = json!({
1528            "status": "success",
1529            "galaxy": galaxy.db_name(),
1530            "total": entries.len(),
1531            "memories": entries,
1532        });
1533        if query_applied {
1534            response["note"] = json!(
1535                "'query' applied as a literal substring filter over content — \
1536                 for tokenized, ranked full-text retrieval use memory.search."
1537            );
1538        }
1539        if created_after.is_some() || created_before.is_some() {
1540            response["time_range"] = json!({
1541                "created_after": created_after
1542                    .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
1543                "created_before": created_before
1544                    .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)),
1545            });
1546        }
1547        Ok(response)
1548    }
1549    fn stats(&self) -> &ToolStats {
1550        &self.stats
1551    }
1552}
1553
1554// ── Tool: memory.search ──────────────────────────────────────────────
1555
1556/// BM25-only search. Not registered: `memory.search` is
1557/// `MemoryHybridRecallTool::as_search` (BM25, hybrid when an embedder exists).
1558#[allow(dead_code)]
1559pub struct MemorySearchTool {
1560    search: Arc<SearchEngine>,
1561    store: Arc<MemoryStore>,
1562    stats: ToolStats,
1563    effects: EffectRow,
1564}
1565
1566impl MemorySearchTool {
1567    #[must_use]
1568    pub fn new(search: Arc<SearchEngine>, store: Arc<MemoryStore>) -> Self {
1569        Self {
1570            search,
1571            store,
1572            stats: ToolStats::default(),
1573            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1574        }
1575    }
1576}
1577
1578#[async_trait]
1579impl Tool for MemorySearchTool {
1580    fn name(&self) -> &str {
1581        "memory.search"
1582    }
1583    fn gana(&self) -> Gana {
1584        Gana::WinnowingBasket
1585    }
1586    fn effects(&self) -> &EffectRow {
1587        &self.effects
1588    }
1589    fn input_schema(&self) -> Value {
1590        schema(
1591            &json!({
1592                "query": str_prop("Full-text query"),
1593                "galaxy": str_prop("Galaxy filter (default: all galaxies)"),
1594                "limit": int_prop("Maximum results (default 20)"),
1595                "min_score": num_prop("Absolute BM25 score floor"),
1596                "min_score_ratio": num_prop("Relative floor: reject hits below this fraction of the top score"),
1597            }),
1598            &["query"],
1599        )
1600    }
1601    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1602        let query = args
1603            .get("query")
1604            .and_then(|v| v.as_str())
1605            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
1606        let limit = args
1607            .get("limit")
1608            .and_then(serde_json::Value::as_u64)
1609            .unwrap_or(20) as usize;
1610        let min_score = args
1611            .get("min_score")
1612            .and_then(serde_json::Value::as_f64)
1613            .map(|v| v as f32)
1614            .filter(|v| *v > 0.0);
1615        let min_score_ratio = args
1616            .get("min_score_ratio")
1617            .and_then(serde_json::Value::as_f64)
1618            .map(|v| v as f32)
1619            .filter(|v| *v > 0.0 && *v < 1.0);
1620        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
1621
1622        let mut opts = wm_memory::SearchOptions {
1623            limit,
1624            min_score,
1625            relative_floor: min_score_ratio,
1626            ..wm_memory::SearchOptions::default()
1627        };
1628        if let Some(g) = galaxy_str {
1629            opts.galaxy = Some(parse_galaxy(g)?);
1630        }
1631        let results = self.search.search_opt(query, &opts)?;
1632
1633        // Stale verification: index entries whose memory no longer exists in
1634        // LMDB are dropped, and the preview comes from the verified LMDB copy.
1635        // Private memories are dropped here too — they never appear in MCP
1636        // search responses.
1637        let entries: Vec<Value> = results
1638            .iter()
1639            .filter_map(|r| {
1640                let galaxy = wm_core::Galaxy::from_db_name(&r.galaxy)?;
1641                let id = uuid::Uuid::parse_str(&r.memory_id).ok()?;
1642                let mem = self.store.get(galaxy, id).ok().flatten()?;
1643                if !crate::expansion::common::mcp_visible(&mem) {
1644                    return None;
1645                }
1646                if !crate::expansion::common::validity_visible(&mem) {
1647                    return None;
1648                }
1649                Some(json!({
1650                    "memory_id": r.memory_id,
1651                    "galaxy": r.galaxy,
1652                    "score": r.score,
1653                    "normalized_score": r.normalized_score,
1654                    "content_preview": wm_memory::scrub_text(&mem.content).chars().take(120).collect::<String>(),
1655                }))
1656            })
1657            .collect();
1658
1659        Ok(json!({
1660            "status": "success",
1661            "query": query,
1662            "total": entries.len(),
1663            "results": entries,
1664        }))
1665    }
1666    fn stats(&self) -> &ToolStats {
1667        &self.stats
1668    }
1669}
1670
1671// ── Tool: memory.chat (conversational search) ─────────────────────
1672
1673/// Conversational memory search with LRU caching and query classification.
1674///
1675/// Wraps `ConversationalSearch` (Phase N5) for sub-50ms hybrid search.
1676pub struct MemoryChatTool {
1677    search: std::sync::Mutex<ConversationalSearch>,
1678    stats: ToolStats,
1679    effects: EffectRow,
1680}
1681
1682impl MemoryChatTool {
1683    #[must_use]
1684    pub fn new(search: ConversationalSearch) -> Self {
1685        Self {
1686            search: std::sync::Mutex::new(search),
1687            stats: ToolStats::default(),
1688            effects: EffectRow::read_only(vec![Resource::Galaxy("codex".into())]),
1689        }
1690    }
1691}
1692
1693#[async_trait]
1694impl Tool for MemoryChatTool {
1695    fn name(&self) -> &str {
1696        "memory.chat"
1697    }
1698    fn gana(&self) -> Gana {
1699        Gana::WinnowingBasket
1700    }
1701    fn effects(&self) -> &EffectRow {
1702        &self.effects
1703    }
1704    fn input_schema(&self) -> Value {
1705        schema(
1706            &json!({
1707                "query": str_prop("Conversational query"),
1708                "galaxy": str_prop("Optional galaxy filter"),
1709                "limit": int_prop("Maximum results"),
1710            }),
1711            &["query"],
1712        )
1713    }
1714    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1715        let query = args
1716            .get("query")
1717            .and_then(|v| v.as_str())
1718            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query (string) required".into()))?;
1719        let limit = args
1720            .get("limit")
1721            .and_then(serde_json::Value::as_u64)
1722            .map(|n| n as usize);
1723        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
1724
1725        let galaxy = match galaxy_str {
1726            Some(g) => Some(parse_galaxy(g)?),
1727            None => None,
1728        };
1729
1730        let (results, metrics) = {
1731            let search = self
1732                .search
1733                .lock()
1734                .map_err(|e| wm_core::CoreError::Tool(format!("search lock: {e}")))?;
1735            let results = search.search_in_galaxy(query, limit, galaxy);
1736            let metrics = search.metrics();
1737            (results, metrics)
1738        };
1739
1740        let entries: Vec<Value> = results
1741            .iter()
1742            .map(|r| {
1743                json!({
1744                    "memory_id": r.memory_id,
1745                    "galaxy": format!("{:?}", r.galaxy),
1746                    "score": r.score,
1747                    "snippet": r.snippet,
1748                    "from_cache": r.from_cache,
1749                    "latency_us": r.latency_us,
1750                })
1751            })
1752            .collect();
1753
1754        Ok(json!({
1755            "status": "success",
1756            "query": query,
1757            "total": entries.len(),
1758            "results": entries,
1759            "metrics": {
1760                "total_queries": metrics.total_queries,
1761                "cache_hits": metrics.cache_hits,
1762                "cache_misses": metrics.cache_misses,
1763                "cache_hit_rate": metrics.cache_hit_rate(),
1764                "avg_latency_ms": metrics.avg_latency_ms(),
1765                "meets_latency_target": metrics.meets_latency_target(),
1766            },
1767        }))
1768    }
1769    fn stats(&self) -> &ToolStats {
1770        &self.stats
1771    }
1772}
1773
1774// ── Tool: memory.vector.search ───────────────────────────────────────
1775
1776/// Vector similarity search over memory embeddings.
1777///
1778/// Searches for memories by embedding vector similarity (cosine similarity).
1779/// Accepts either a raw embedding vector or a memory ID to find similar memories.
1780/// Optionally filters by galaxy.
1781pub struct MemoryVectorSearchTool {
1782    store: Arc<MemoryStore>,
1783    vector_store: Arc<std::sync::Mutex<VectorStore>>,
1784    stats: ToolStats,
1785    effects: EffectRow,
1786}
1787
1788impl MemoryVectorSearchTool {
1789    /// Create a new vector search tool.
1790    ///
1791    /// The `VectorStore` is lazily loaded from LMDB on first search.
1792    #[must_use]
1793    pub fn new(store: Arc<MemoryStore>, vector_store: Arc<std::sync::Mutex<VectorStore>>) -> Self {
1794        Self {
1795            store,
1796            vector_store,
1797            stats: ToolStats::default(),
1798            effects: EffectRow::read_only(vec![Resource::VectorStore]),
1799        }
1800    }
1801
1802    /// Ensure the vector store is loaded from LMDB.
1803    fn ensure_loaded(&self) -> wm_core::Result<()> {
1804        let mut vs = self
1805            .vector_store
1806            .lock()
1807            .map_err(|e| wm_core::CoreError::Tool(format!("vector store lock: {e}")))?;
1808        if !vs.is_loaded() {
1809            vs.load(&self.store)?;
1810        }
1811        drop(vs);
1812        Ok(())
1813    }
1814}
1815
1816#[async_trait]
1817impl Tool for MemoryVectorSearchTool {
1818    fn name(&self) -> &str {
1819        "memory.vector.search"
1820    }
1821    fn gana(&self) -> Gana {
1822        Gana::WinnowingBasket
1823    }
1824    fn effects(&self) -> &EffectRow {
1825        &self.effects
1826    }
1827    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1828        self.ensure_loaded()?;
1829
1830        let limit = args
1831            .get("limit")
1832            .and_then(serde_json::Value::as_u64)
1833            .unwrap_or(10) as usize;
1834        let galaxy_str = args.get("galaxy").and_then(|v| v.as_str());
1835        let galaxy_filter = match galaxy_str {
1836            Some(g) => Some(parse_galaxy(g)?),
1837            None => None,
1838        };
1839
1840        // Two modes: search by embedding vector, or search by memory ID
1841        let results = if let Some(id_str) = args.get("memory_id").and_then(|v| v.as_str()) {
1842            // Search similar to a given memory ID
1843            let memory_id = uuid::Uuid::parse_str(id_str).map_err(|e| {
1844                wm_core::CoreError::InvalidArgs(format!("Invalid memory_id UUID: {e}"))
1845            })?;
1846
1847            let vs = self
1848                .vector_store
1849                .lock()
1850                .map_err(|e| wm_core::CoreError::Tool(format!("vector store lock: {e}")))?;
1851            vs.search_similar_to(memory_id, limit)
1852        } else if let Some(embedding_arr) = args.get("embedding").and_then(|v| v.as_array()) {
1853            // Search by raw embedding vector
1854            let embedding: Vec<f32> = embedding_arr
1855                .iter()
1856                .filter_map(|v| v.as_f64().map(|f| f as f32))
1857                .collect();
1858
1859            if embedding.is_empty() {
1860                return Err(wm_core::CoreError::InvalidArgs(
1861                    "embedding (array of numbers) or memory_id (string) required".into(),
1862                ));
1863            }
1864
1865            let vs = self
1866                .vector_store
1867                .lock()
1868                .map_err(|e| wm_core::CoreError::Tool(format!("vector store lock: {e}")))?;
1869            vs.search(&embedding, limit, galaxy_filter)
1870        } else {
1871            return Err(wm_core::CoreError::InvalidArgs(
1872                "Either 'embedding' (array of floats) or 'memory_id' (UUID string) is required"
1873                    .into(),
1874            ));
1875        };
1876
1877        let entries: Vec<Value> = results
1878            .iter()
1879            .filter_map(|r| {
1880                // Fetch content preview from the verified LMDB copy. Private
1881                // memories never appear in MCP vector search responses.
1882                // Vector-store entries without a backing memory keep their
1883                // slot with an empty preview (unverifiable, no content leak).
1884                let stored = self.store.get(r.galaxy, r.memory_id).ok().flatten();
1885                if let Some(mem) = &stored {
1886                    if !crate::expansion::common::mcp_visible(mem) {
1887                        return None;
1888                    }
1889                    if !crate::expansion::common::validity_visible(mem) {
1890                        return None;
1891                    }
1892                }
1893                let preview = stored
1894                    .map(|m| m.content.chars().take(120).collect::<String>())
1895                    .unwrap_or_default();
1896                Some(json!({
1897                    "memory_id": r.memory_id.to_string(),
1898                    "galaxy": r.galaxy.db_name(),
1899                    "score": r.score,
1900                    "content_preview": preview,
1901                }))
1902            })
1903            .collect();
1904
1905        Ok(json!({
1906            "status": "success",
1907            "total": entries.len(),
1908            "results": entries,
1909        }))
1910    }
1911    fn stats(&self) -> &ToolStats {
1912        &self.stats
1913    }
1914}
1915
1916// ── Tool: memory.associate ───────────────────────────────────────────
1917
1918/// Create a cross-galaxy association between two memories.
1919pub struct MemoryAssociateTool {
1920    store: Arc<MemoryStore>,
1921    stats: ToolStats,
1922    effects: EffectRow,
1923}
1924
1925impl MemoryAssociateTool {
1926    pub fn new(store: Arc<MemoryStore>) -> Self {
1927        Self {
1928            store,
1929            stats: ToolStats::default(),
1930            effects: EffectRow {
1931                writes: vec![Resource::Galaxy("associations".into())],
1932                invokes: vec![Capability::MemoryWrite],
1933                ..Default::default()
1934            },
1935        }
1936    }
1937}
1938
1939#[async_trait]
1940impl Tool for MemoryAssociateTool {
1941    fn name(&self) -> &str {
1942        "memory.associate"
1943    }
1944    fn gana(&self) -> Gana {
1945        Gana::Net
1946    }
1947    fn effects(&self) -> &EffectRow {
1948        &self.effects
1949    }
1950    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1951        let source_str = args.get("source").and_then(|v| v.as_str()).ok_or_else(|| {
1952            wm_core::CoreError::InvalidArgs("source (UUID string) required".into())
1953        })?;
1954        let target_str = args.get("target").and_then(|v| v.as_str()).ok_or_else(|| {
1955            wm_core::CoreError::InvalidArgs("target (UUID string) required".into())
1956        })?;
1957        let source = uuid::Uuid::parse_str(source_str)
1958            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid source UUID: {e}")))?;
1959        let target = uuid::Uuid::parse_str(target_str)
1960            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid target UUID: {e}")))?;
1961        let weight = args
1962            .get("weight")
1963            .and_then(serde_json::Value::as_f64)
1964            .unwrap_or(1.0) as f32;
1965        let assoc_type = args
1966            .get("type")
1967            .and_then(|v| v.as_str())
1968            .unwrap_or("related");
1969        let link_type = wm_memory::LinkType::from_str_lossy(assoc_type);
1970
1971        let assoc = Association::new(source, target, link_type, weight);
1972        let assoc_store = AssociationStore::open(self.store.env())?;
1973        assoc_store.put(self.store.env(), &assoc)?;
1974
1975        Ok(json!({
1976            "status": "success",
1977            "source": source_str,
1978            "target": target_str,
1979            "weight": weight,
1980        }))
1981    }
1982    fn stats(&self) -> &ToolStats {
1983        &self.stats
1984    }
1985}
1986
1987// ── Tool: memory.associations ────────────────────────────────────────
1988
1989/// Find associations for a memory (incoming or outgoing).
1990pub struct MemoryAssociationsTool {
1991    store: Arc<MemoryStore>,
1992    stats: ToolStats,
1993    effects: EffectRow,
1994}
1995
1996impl MemoryAssociationsTool {
1997    pub fn new(store: Arc<MemoryStore>) -> Self {
1998        Self {
1999            store,
2000            stats: ToolStats::default(),
2001            effects: EffectRow::read_only(vec![Resource::Galaxy("associations".into())]),
2002        }
2003    }
2004}
2005
2006#[async_trait]
2007impl Tool for MemoryAssociationsTool {
2008    fn name(&self) -> &str {
2009        "memory.associations"
2010    }
2011    fn gana(&self) -> Gana {
2012        Gana::Net
2013    }
2014    fn effects(&self) -> &EffectRow {
2015        &self.effects
2016    }
2017    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2018        let id_str = args
2019            .get("id")
2020            .and_then(|v| v.as_str())
2021            .ok_or_else(|| wm_core::CoreError::InvalidArgs("id (UUID string) required".into()))?;
2022        let id = uuid::Uuid::parse_str(id_str)
2023            .map_err(|e| wm_core::CoreError::InvalidArgs(format!("invalid UUID: {e}")))?;
2024        let direction = args
2025            .get("direction")
2026            .and_then(|v| v.as_str())
2027            .unwrap_or("both");
2028
2029        let assoc_store = AssociationStore::open(self.store.env())?;
2030
2031        let mut entries = Vec::new();
2032
2033        if direction == "from" || direction == "both" {
2034            for a in assoc_store.find_from(self.store.env(), id)? {
2035                entries.push(json!({
2036                    "source": a.source.to_string(),
2037                    "target": a.target.to_string(),
2038                    "weight": a.weight,
2039                    "link_type": a.link_type.as_str(),
2040                    "co_activation_count": a.co_activation_count,
2041                    "direction": "outgoing",
2042                }));
2043            }
2044        }
2045        if direction == "to" || direction == "both" {
2046            for a in assoc_store.find_to(self.store.env(), id)? {
2047                entries.push(json!({
2048                    "source": a.source.to_string(),
2049                    "target": a.target.to_string(),
2050                    "weight": a.weight,
2051                    "link_type": a.link_type.as_str(),
2052                    "co_activation_count": a.co_activation_count,
2053                    "direction": "incoming",
2054                }));
2055            }
2056        }
2057
2058        let total = assoc_store.count(self.store.env())?;
2059
2060        Ok(json!({
2061            "status": "success",
2062            "id": id_str,
2063            "direction": direction,
2064            "associations": entries,
2065            "returned": entries.len(),
2066            "total_in_store": total,
2067        }))
2068    }
2069    fn stats(&self) -> &ToolStats {
2070        &self.stats
2071    }
2072}
2073
2074// ── Tool: karma.report ───────────────────────────────────────────────
2075
2076/// Report karma ledger status: total debt, recent entries, per-tool breakdown.
2077pub struct KarmaReportTool {
2078    ledger: Arc<KarmaLedger>,
2079    stats: ToolStats,
2080    effects: EffectRow,
2081}
2082
2083impl KarmaReportTool {
2084    pub fn new(ledger: Arc<KarmaLedger>) -> Self {
2085        Self {
2086            ledger,
2087            stats: ToolStats::default(),
2088            effects: EffectRow::read_only(vec![Resource::Galaxy("karma".into())]),
2089        }
2090    }
2091}
2092
2093#[async_trait]
2094impl Tool for KarmaReportTool {
2095    fn name(&self) -> &str {
2096        "karma.report"
2097    }
2098    fn gana(&self) -> Gana {
2099        Gana::Willow
2100    }
2101    fn effects(&self) -> &EffectRow {
2102        &self.effects
2103    }
2104    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2105        let recent_count = args
2106            .get("limit")
2107            .and_then(serde_json::Value::as_u64)
2108            .unwrap_or(10) as usize;
2109
2110        let recent = self.ledger.recent(recent_count)?;
2111        let tool_debt = self.ledger.tool_debt()?;
2112
2113        let recent_entries: Vec<Value> = recent
2114            .iter()
2115            .map(|e| {
2116                json!({
2117                    "id": e.id,
2118                    "tool": e.tool,
2119                    "success": e.success,
2120                    "mismatch": e.mismatch,
2121                    "debt_delta": e.debt_delta,
2122                    "guna": format!("{:?}", e.guna),
2123                    "total_debt": e.total_debt,
2124                })
2125            })
2126            .collect();
2127
2128        let tool_debt_entries: Vec<Value> = tool_debt
2129            .iter()
2130            .map(|(tool, debt)| {
2131                json!({
2132                    "tool": tool,
2133                    "debt": debt,
2134                })
2135            })
2136            .collect();
2137
2138        Ok(json!({
2139            "status": "success",
2140            "total_debt": self.ledger.total_debt(),
2141            "chain_head": self.ledger.chain_head(),
2142            "entry_count": self.ledger.next_id(),
2143            "recent_entries": recent_entries,
2144            "per_tool_debt": tool_debt_entries,
2145        }))
2146    }
2147    fn stats(&self) -> &ToolStats {
2148        &self.stats
2149    }
2150}
2151
2152// ── Tool: dharma.status ──────────────────────────────────────────────
2153
2154/// Report Dharma gate state: homeostasis, health score, strict mode.
2155pub struct DharmaStatusTool {
2156    gate: Arc<DharmaGate>,
2157    stats: ToolStats,
2158    effects: EffectRow,
2159}
2160
2161impl DharmaStatusTool {
2162    pub fn new(gate: Arc<DharmaGate>) -> Self {
2163        Self {
2164            gate,
2165            stats: ToolStats::default(),
2166            effects: EffectRow::pure(),
2167        }
2168    }
2169}
2170
2171#[async_trait]
2172impl Tool for DharmaStatusTool {
2173    fn name(&self) -> &str {
2174        "dharma.status"
2175    }
2176    fn gana(&self) -> Gana {
2177        Gana::ExtendedNet
2178    }
2179    fn effects(&self) -> &EffectRow {
2180        &self.effects
2181    }
2182    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
2183        let homeostasis = self.gate.homeostasis();
2184        let health = homeostasis.health_score();
2185
2186        Ok(json!({
2187            "status": "success",
2188            "homeostasis": {
2189                "cpu_load": homeostasis.cpu_load,
2190                "memory_pressure": homeostasis.memory_pressure,
2191                "active": homeostasis.active,
2192                "health_score": health,
2193                "stressed": homeostasis.is_stressed(),
2194            },
2195            "sutras": {
2196                "ahimsa": "Non-harm — destructive actions blocked in strict mode",
2197                "satya": "Truth — memory fabrication always forbidden",
2198            },
2199        }))
2200    }
2201    fn stats(&self) -> &ToolStats {
2202        &self.stats
2203    }
2204}
2205
2206// ── Tool: harmony.vector ─────────────────────────────────────────────
2207
2208/// Report current Harmony Vector — real-time hardware state (Lakshmi).
2209pub struct HarmonyVectorTool {
2210    monitor: Arc<SubstrateMonitor>,
2211    stats: ToolStats,
2212    effects: EffectRow,
2213}
2214
2215impl HarmonyVectorTool {
2216    pub fn new(monitor: Arc<SubstrateMonitor>) -> Self {
2217        Self {
2218            monitor,
2219            stats: ToolStats::default(),
2220            effects: EffectRow::pure(),
2221        }
2222    }
2223}
2224
2225#[async_trait]
2226impl Tool for HarmonyVectorTool {
2227    fn name(&self) -> &str {
2228        "harmony.vector"
2229    }
2230    fn gana(&self) -> Gana {
2231        Gana::Dipper
2232    }
2233    fn effects(&self) -> &EffectRow {
2234        &self.effects
2235    }
2236    async fn call(&self, _ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
2237        let hv = self.monitor.sample();
2238        Ok(json!({
2239            "status": "success",
2240            "harmony_vector": hv.to_json(),
2241        }))
2242    }
2243    fn stats(&self) -> &ToolStats {
2244        &self.stats
2245    }
2246}
2247
2248// ── Tool: harmony.history ────────────────────────────────────────────
2249
2250/// Report historical Harmony Vector samples.
2251pub struct HarmonyHistoryTool {
2252    monitor: Arc<SubstrateMonitor>,
2253    stats: ToolStats,
2254    effects: EffectRow,
2255}
2256
2257impl HarmonyHistoryTool {
2258    pub fn new(monitor: Arc<SubstrateMonitor>) -> Self {
2259        Self {
2260            monitor,
2261            stats: ToolStats::default(),
2262            effects: EffectRow::pure(),
2263        }
2264    }
2265}
2266
2267#[async_trait]
2268impl Tool for HarmonyHistoryTool {
2269    fn name(&self) -> &str {
2270        "harmony.history"
2271    }
2272    fn gana(&self) -> Gana {
2273        Gana::Dipper
2274    }
2275    fn effects(&self) -> &EffectRow {
2276        &self.effects
2277    }
2278    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2279        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize;
2280        let samples: Vec<Value> = self
2281            .monitor
2282            .history(limit)
2283            .iter()
2284            .map(wm_substrate::HarmonyVector::to_json)
2285            .collect();
2286        Ok(json!({
2287            "status": "success",
2288            "count": samples.len(),
2289            "samples": samples,
2290        }))
2291    }
2292    fn stats(&self) -> &ToolStats {
2293        &self.stats
2294    }
2295}
2296
2297// ── Tool: gnosis.status ──────────────────────────────────────────────
2298
2299/// Full governance transparency — homeostasis, resource rules, brain-wave.
2300///
2301/// The Gnosis Portal exposes the complete governance state for human
2302/// inspection. This is the transparency layer — every autonomous
2303/// action's governance context is visible here.
2304pub struct GnosisStatusTool {
2305    dharma_gate: Arc<DharmaGate>,
2306    resource_rules: Arc<ResourceRules>,
2307    substrate: Arc<SubstrateMonitor>,
2308    stats: ToolStats,
2309    effects: EffectRow,
2310}
2311
2312impl GnosisStatusTool {
2313    pub fn new(
2314        dharma_gate: Arc<DharmaGate>,
2315        resource_rules: Arc<ResourceRules>,
2316        substrate: Arc<SubstrateMonitor>,
2317    ) -> Self {
2318        Self {
2319            dharma_gate,
2320            resource_rules,
2321            substrate,
2322            stats: ToolStats::default(),
2323            effects: EffectRow::pure(),
2324        }
2325    }
2326}
2327
2328#[async_trait]
2329impl Tool for GnosisStatusTool {
2330    fn name(&self) -> &str {
2331        "gnosis.status"
2332    }
2333    fn gana(&self) -> Gana {
2334        Gana::ThreeStars
2335    }
2336    fn effects(&self) -> &EffectRow {
2337        &self.effects
2338    }
2339    async fn call(&self, ctx: &mut Context, _args: Value) -> wm_core::Result<Value> {
2340        let homeostasis = self.dharma_gate.homeostasis();
2341        let health = homeostasis.health_score();
2342        let budget_usage = self.resource_rules.budget_usage();
2343        let human_approved = self.resource_rules.human_approved();
2344        let last_hv = self.substrate.last_sample();
2345
2346        Ok(json!({
2347            "status": "success",
2348            "brain_wave": format!("{:?}", ctx.brain_wave),
2349            "homeostasis": {
2350                "cpu_load": homeostasis.cpu_load,
2351                "memory_pressure": homeostasis.memory_pressure,
2352                "active": homeostasis.active,
2353                "health_score": health,
2354                "stressed": homeostasis.is_stressed(),
2355            },
2356            "resource_rules": {
2357                "writes_last_minute": budget_usage.writes_last_minute,
2358                "spawns_last_minute": budget_usage.spawns_last_minute,
2359                "network_last_minute": budget_usage.network_last_minute,
2360                "novelty_entries": budget_usage.novelty_entries,
2361                "human_approved": human_approved,
2362                "require_human_review": true,
2363            },
2364            "substrate": last_hv.as_ref().map(wm_substrate::HarmonyVector::to_json),
2365            "governance_layers": {
2366                "lakshmi": "Harmony Vector — hardware awareness (active)",
2367                "tiferet": "Resource Gating — brain-wave transitions gated by health (active)",
2368                "yama": "Dharma Resource Rules — budgets, novelty, purpose, human review (active)",
2369                "gnosis": "Transparency Portals — this tool (active)",
2370            },
2371        }))
2372    }
2373    fn stats(&self) -> &ToolStats {
2374        &self.stats
2375    }
2376}
2377
2378// ── Tool: gnosis.history ─────────────────────────────────────────────
2379
2380/// Historical governance data — harmony vector history and budget trends.
2381pub struct GnosisHistoryTool {
2382    substrate: Arc<SubstrateMonitor>,
2383    stats: ToolStats,
2384    effects: EffectRow,
2385}
2386
2387impl GnosisHistoryTool {
2388    pub fn new(substrate: Arc<SubstrateMonitor>) -> Self {
2389        Self {
2390            substrate,
2391            stats: ToolStats::default(),
2392            effects: EffectRow::pure(),
2393        }
2394    }
2395}
2396
2397#[async_trait]
2398impl Tool for GnosisHistoryTool {
2399    fn name(&self) -> &str {
2400        "gnosis.history"
2401    }
2402    fn gana(&self) -> Gana {
2403        Gana::ThreeStars
2404    }
2405    fn effects(&self) -> &EffectRow {
2406        &self.effects
2407    }
2408    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2409        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize;
2410        let history = self.substrate.history(limit);
2411        let samples: Vec<Value> = history
2412            .iter()
2413            .map(wm_substrate::HarmonyVector::to_json)
2414            .collect();
2415
2416        // Compute summary stats
2417        let avg_cpu = if samples.is_empty() {
2418            0.0
2419        } else {
2420            samples
2421                .iter()
2422                .filter_map(|s| s["cpu_load"].as_f64())
2423                .sum::<f64>()
2424                / samples.len() as f64
2425        };
2426        let avg_mem = if samples.is_empty() {
2427            0.0
2428        } else {
2429            samples
2430                .iter()
2431                .filter_map(|s| s["memory_pressure"].as_f64())
2432                .sum::<f64>()
2433                / samples.len() as f64
2434        };
2435        let avg_health = if samples.is_empty() {
2436            0.0
2437        } else {
2438            samples
2439                .iter()
2440                .filter_map(|s| s["health_score"].as_f64())
2441                .sum::<f64>()
2442                / samples.len() as f64
2443        };
2444
2445        Ok(json!({
2446            "status": "success",
2447            "count": samples.len(),
2448            "summary": {
2449                "avg_cpu_load": avg_cpu,
2450                "avg_memory_pressure": avg_mem,
2451                "avg_health_score": avg_health,
2452            },
2453            "samples": samples,
2454        }))
2455    }
2456    fn stats(&self) -> &ToolStats {
2457        &self.stats
2458    }
2459}
2460
2461// ── Tool: gnosis.explain ─────────────────────────────────────────────
2462
2463/// Explain governance decisions — why an action was allowed or blocked.
2464///
2465/// Given a tool name and its effects, returns the governance verdict
2466/// from each layer (Dharma gate, resource rules) so humans can
2467/// understand exactly why the system made its decision.
2468pub struct GnosisExplainTool {
2469    dharma_gate: Arc<DharmaGate>,
2470    resource_rules: Arc<ResourceRules>,
2471    stats: ToolStats,
2472    effects: EffectRow,
2473}
2474
2475impl GnosisExplainTool {
2476    pub fn new(dharma_gate: Arc<DharmaGate>, resource_rules: Arc<ResourceRules>) -> Self {
2477        Self {
2478            dharma_gate,
2479            resource_rules,
2480            stats: ToolStats::default(),
2481            effects: EffectRow::pure(),
2482        }
2483    }
2484}
2485
2486#[async_trait]
2487impl Tool for GnosisExplainTool {
2488    fn name(&self) -> &str {
2489        "gnosis.explain"
2490    }
2491    fn gana(&self) -> Gana {
2492        Gana::ThreeStars
2493    }
2494    fn effects(&self) -> &EffectRow {
2495        &self.effects
2496    }
2497    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
2498        let tool_name = args
2499            .get("tool_name")
2500            .and_then(Value::as_str)
2501            .unwrap_or("unknown");
2502        let is_write = args
2503            .get("is_write")
2504            .and_then(Value::as_bool)
2505            .unwrap_or(false);
2506        let is_spawn = args
2507            .get("is_spawn")
2508            .and_then(Value::as_bool)
2509            .unwrap_or(false);
2510        let is_network = args
2511            .get("is_network")
2512            .and_then(Value::as_bool)
2513            .unwrap_or(false);
2514        let has_purpose = args
2515            .get("has_purpose")
2516            .and_then(Value::as_bool)
2517            .unwrap_or(true);
2518        let args_hash = args.get("args_hash").and_then(Value::as_u64).unwrap_or(0);
2519
2520        let homeostasis = self.dharma_gate.homeostasis();
2521
2522        // Get Dharma gate verdict
2523        let dummy_effects = if is_write {
2524            EffectRow {
2525                writes: vec![Resource::Filesystem],
2526                ..Default::default()
2527            }
2528        } else {
2529            EffectRow::pure()
2530        };
2531        let dharma_verdict = self.dharma_gate.evaluate(&dummy_effects, ctx);
2532
2533        // Get resource rules verdict
2534        let resource_verdict = self.resource_rules.evaluate(
2535            tool_name,
2536            args_hash,
2537            is_write,
2538            is_spawn,
2539            is_network,
2540            has_purpose,
2541            &homeostasis,
2542            ctx.brain_wave,
2543        );
2544
2545        Ok(json!({
2546            "status": "success",
2547            "tool_name": tool_name,
2548            "brain_wave": format!("{:?}", ctx.brain_wave),
2549            "homeostasis": {
2550                "cpu_load": homeostasis.cpu_load,
2551                "memory_pressure": homeostasis.memory_pressure,
2552                "health_score": homeostasis.health_score(),
2553                "stressed": homeostasis.is_stressed(),
2554            },
2555            "dharma_verdict": {
2556                "verdict": format!("{:?}", dharma_verdict),
2557                "blocks": dharma_verdict.blocks(),
2558                "reason": dharma_verdict.reason(),
2559            },
2560            "resource_verdict": {
2561                "verdict": format!("{:?}", resource_verdict),
2562                "blocks": resource_verdict.blocks(),
2563                "reason": resource_verdict.reason(),
2564            },
2565            "would_block": dharma_verdict.blocks() || resource_verdict.blocks(),
2566            "explanation": format!(
2567                "Tool '{}' under {:?} brain-wave with health {:.2}: Dharma says '{}', Resources say '{}'. {}",
2568                tool_name,
2569                ctx.brain_wave,
2570                homeostasis.health_score(),
2571                dharma_verdict.reason(),
2572                resource_verdict.reason(),
2573                if dharma_verdict.blocks() || resource_verdict.blocks() {
2574                    "Action would be BLOCKED."
2575                } else {
2576                    "Action would be ALLOWED."
2577                }
2578            ),
2579        }))
2580    }
2581    fn stats(&self) -> &ToolStats {
2582        &self.stats
2583    }
2584}
2585
2586// ── Fractal Meta-Tool: wm ────────────────────────────────────────────
2587
2588/// The fractal meta-tool — routes natural language or explicit route to tools.
2589pub struct WmMetaTool {
2590    registry: Arc<ToolRegistry>,
2591    stats: ToolStats,
2592    effects: EffectRow,
2593    /// Optional embedding-based NLU router. When present, used as primary router
2594    /// with TF-IDF as fallback (shadow mode). When `None`, TF-IDF is used directly.
2595    embedding_router: Option<Arc<embedding_router::EmbeddingRouter>>,
2596    /// Shadow mode disagreement stats (shared for observability).
2597    shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
2598    /// Optional dispatch pipeline. When present, inner tool calls are dispatched
2599    /// through the full governance chain (effect check, destructive confirmation,
2600    /// dharma gate, rate limit, circuit breaker, karma record, stats). When
2601    /// `None` (e.g. in unit tests), inner calls bypass the pipeline.
2602    pipeline: Option<Arc<DispatchPipeline>>,
2603}
2604
2605impl WmMetaTool {
2606    #[must_use]
2607    pub fn new(registry: Arc<ToolRegistry>) -> Self {
2608        Self {
2609            registry,
2610            stats: ToolStats::default(),
2611            effects: EffectRow::pure(),
2612            embedding_router: None,
2613            shadow_stats: Arc::new(std::sync::RwLock::new(
2614                embedding_router::ShadowModeStats::default(),
2615            )),
2616            pipeline: None,
2617        }
2618    }
2619
2620    /// Create a new meta-tool with an embedding router.
2621    ///
2622    /// If the embedder is a stub, the embedding router will be `None` and the
2623    /// TF-IDF router is used as fallback.
2624    #[must_use]
2625    pub fn with_embedder(
2626        registry: Arc<ToolRegistry>,
2627        embedder: Box<dyn wm_memory::Embedder>,
2628    ) -> Self {
2629        let embedding_router = Self::build_embedding_router(&registry, embedder).map(Arc::new);
2630        Self {
2631            registry,
2632            stats: ToolStats::default(),
2633            effects: EffectRow::pure(),
2634            embedding_router,
2635            shadow_stats: Arc::new(std::sync::RwLock::new(
2636                embedding_router::ShadowModeStats::default(),
2637            )),
2638            pipeline: None,
2639        }
2640    }
2641
2642    /// Create a new meta-tool with an embedding router and shared shadow stats.
2643    ///
2644    /// Allows the caller to hold a reference to the shadow stats for
2645    /// observability and persistence.
2646    #[must_use]
2647    pub fn with_embedder_and_shadow_stats(
2648        registry: Arc<ToolRegistry>,
2649        embedder: Box<dyn wm_memory::Embedder>,
2650        shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
2651    ) -> Self {
2652        let embedding_router = Self::build_embedding_router(&registry, embedder).map(Arc::new);
2653        Self {
2654            registry,
2655            stats: ToolStats::default(),
2656            effects: EffectRow::pure(),
2657            embedding_router,
2658            shadow_stats,
2659            pipeline: None,
2660        }
2661    }
2662
2663    /// Create a new meta-tool with an embedding router, shared shadow stats,
2664    /// and a dispatch pipeline for governance-gated inner dispatch.
2665    #[must_use]
2666    pub fn with_router_shadow_stats_and_pipeline(
2667        registry: Arc<ToolRegistry>,
2668        embedder: Box<dyn wm_memory::Embedder>,
2669        shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
2670        pipeline: Option<Arc<DispatchPipeline>>,
2671    ) -> Self {
2672        let embedding_router = Self::build_embedding_router(&registry, embedder).map(Arc::new);
2673        Self {
2674            registry,
2675            stats: ToolStats::default(),
2676            effects: EffectRow::pure(),
2677            embedding_router,
2678            shadow_stats,
2679            pipeline,
2680        }
2681    }
2682
2683    /// Build an embedding router from the live registry's tool descriptions.
2684    ///
2685    /// Uses prose descriptions from the registered tools (name + description)
2686    /// augmented with intent anchors (natural query phrasings per tool), which
2687    /// embed far better than the static keyword-mashup profiles. Only falls
2688    /// back to the static profiles when the registry has no tools (e.g. in
2689    /// unit tests that call `with_embedder` directly).
2690    fn build_embedding_router(
2691        registry: &ToolRegistry,
2692        embedder: Box<dyn wm_memory::Embedder>,
2693    ) -> Option<embedding_router::EmbeddingRouter> {
2694        let tools = registry.all_ref();
2695        if tools.is_empty() {
2696            return embedding_router::EmbeddingRouter::new(embedder);
2697        }
2698        let descriptions = embedding_router::anchored_descriptions(tools);
2699        embedding_router::EmbeddingRouter::with_descriptions(embedder, descriptions)
2700    }
2701
2702    /// Classify natural language input into (tool_name, confidence).
2703    ///
2704    /// When an embedding router is available, uses it as primary. Falls back to
2705    /// the TF-IDF router (`nlu::classify`) when no embedding router is configured
2706    /// or as a shadow-mode comparison.
2707    fn classify(text: &str) -> (&'static str, f64) {
2708        nlu::classify(text)
2709    }
2710
2711    /// Classification core shared by the async wrapper. Runs the embedding
2712    /// router (and shadow TF-IDF comparison) synchronously — callers place it
2713    /// on the blocking pool because the HTTP embedder does synchronous
2714    /// network I/O (ureq), which must not run on the tokio worker thread.
2715    ///
2716    /// Returns the query embedding alongside the routing decision when the
2717    /// embedding router computed one, so the caller can reuse it for OATS
2718    /// outcome recording (one embedder round-trip instead of two).
2719    fn classify_with_router_inner(
2720        router: &embedding_router::EmbeddingRouter,
2721        shadow_stats: &std::sync::RwLock<embedding_router::ShadowModeStats>,
2722        text: &str,
2723    ) -> (String, f64, Option<Vec<f32>>) {
2724        let (emb_tool, emb_conf, margin, query_emb) =
2725            match router.route_with_margin_and_embedding(text) {
2726                Some(t) => t,
2727                None => ("gnosis".into(), 0.0, 0.0, Vec::new()),
2728            };
2729
2730        // Shadow mode: run TF-IDF in parallel and track disagreements
2731        let (tfidf_tool, tfidf_conf) = nlu::classify(text);
2732        if emb_tool != tfidf_tool {
2733            tracing::debug!(
2734                query = text.chars().take(100).collect::<String>(),
2735                embedding_tool = %emb_tool,
2736                embedding_conf = emb_conf,
2737                margin = margin,
2738                tfidf_tool = %tfidf_tool,
2739                tfidf_conf = tfidf_conf,
2740                "shadow mode disagreement: embedding vs TF-IDF"
2741            );
2742        }
2743
2744        // Record in shadow stats tracker
2745        if let Ok(mut stats) = shadow_stats.write() {
2746            stats.record(text, &emb_tool, emb_conf, tfidf_tool, tfidf_conf);
2747        }
2748
2749        // Margin fallback: defer to TF-IDF when the embedding router
2750        // cannot separate the top candidates. TF-IDF's keyword-driven
2751        // picks stay reliable even at low confidence (2026-08-11 data:
2752        // a confidence floor on this fallback caused net regressions).
2753        let selected = if margin < embedding_router::MIN_MARGIN {
2754            (tfidf_tool.to_string(), tfidf_conf)
2755        } else {
2756            (emb_tool, emb_conf)
2757        };
2758        let query_emb = (!query_emb.is_empty()).then_some(query_emb);
2759        (selected.0, selected.1, query_emb)
2760    }
2761
2762    /// Classify a thought off the async worker thread.
2763    ///
2764    /// The embedding router performs synchronous HTTP against the embedder
2765    /// endpoint (`ureq`); running it inline on the tokio worker would block
2766    /// every other dispatch on that worker for the duration of the embedder
2767    /// round-trip. Falls back to TF-IDF on the current thread when no
2768    /// embedding router is configured or the blocking task fails to join.
2769    async fn classify_async(&self, text: &str) -> (String, f64, Option<Vec<f32>>) {
2770        let Some(router) = self.embedding_router.clone() else {
2771            let (tool, conf) = Self::classify(text);
2772            return (tool.to_string(), conf, None);
2773        };
2774        let shadow_stats = Arc::clone(&self.shadow_stats);
2775        let text_owned = text.to_string();
2776        let fallback_text = text_owned.clone();
2777        match tokio::task::spawn_blocking(move || {
2778            Self::classify_with_router_inner(&router, &shadow_stats, &text_owned)
2779        })
2780        .await
2781        {
2782            Ok(result) => result,
2783            Err(join_err) => {
2784                tracing::warn!(
2785                    error = %join_err,
2786                    "NLU blocking classifier task failed — falling back to TF-IDF"
2787                );
2788                let (tool, conf) = Self::classify(&fallback_text);
2789                (tool.to_string(), conf, None)
2790            }
2791        }
2792    }
2793
2794    /// Get a reference to the shadow mode stats for observability.
2795    #[must_use]
2796    pub const fn shadow_stats(&self) -> &Arc<std::sync::RwLock<embedding_router::ShadowModeStats>> {
2797        &self.shadow_stats
2798    }
2799
2800    /// Get a reference to the embedding router, if present.
2801    #[must_use]
2802    pub const fn embedding_router(&self) -> Option<&Arc<embedding_router::EmbeddingRouter>> {
2803        self.embedding_router.as_ref()
2804    }
2805
2806    /// Returns the required parameter for a tool, if any.
2807    /// Tools not listed here either have no required args or accept passthrough.
2808    fn required_arg(tool_name: &str) -> Option<&'static str> {
2809        match tool_name {
2810            "memory.create" => Some("content"),
2811            "memory.batch_create" => Some("items"),
2812            "memory.read" => Some("id"),
2813            "memory.delete" => Some("id"),
2814            "memory.search" => Some("query"),
2815            "memory.episodic_search" => Some("query"),
2816            "memory.query" => Some("query"),
2817            "memory.associate" => Some("source"),
2818            "memory.associations" => Some("id"),
2819            "memory.update" => Some("id"),
2820            "memory.revisions" => Some("id"),
2821            "memory.tag" => Some("id"),
2822            "memory.batch_read" => Some("ids"),
2823            "memory.nearby" => Some("query"),
2824            "session.end" => Some("session_id"),
2825            "agent.register" => Some("name"),
2826            "agent.trust" => Some("agent_id"),
2827            "agent.descriptions" => Some("agent_id"),
2828            "agent.capabilities" => Some("agent_id"),
2829            "agent.heartbeat.history" => Some("agent_id"),
2830            "agent.deregister" => Some("agent_id"),
2831            "galaxy.purge" => Some("galaxy"),
2832            "memory.deduplicate" => Some("galaxy"),
2833            "task.distribute" => Some("task"),
2834            "code.claim" => Some("scope"),
2835            "code.check" => Some("scope"),
2836            "code.release" => Some("scope"),
2837            _ => None,
2838        }
2839    }
2840
2841    /// Build a helpful hint message for a missing required argument.
2842    fn missing_arg_hint(tool_name: &str, missing: &str) -> String {
2843        match (tool_name, missing) {
2844            ("memory.create", "content") => "Provide the content to store, e.g. wm(thought='remember that rust is fast')".into(),
2845            ("memory.read", "id") => "Provide a memory UUID, e.g. wm(thought='recall <uuid>') or wm(route='memory.read', args={\"id\": \"<uuid>\"}). To list memories instead, use wm(route='memory.list', args={\"galaxy\": \"codex\", \"limit\": 10})".into(),
2846            ("memory.delete", "id") => "Provide a memory UUID, e.g. wm(thought='delete memory <uuid>')".into(),
2847            ("memory.search", "query") => "Provide a search query, e.g. wm(thought='search for rust')".into(),
2848            ("memory.query", "query") => "Provide a literal substring to match against memory content, e.g. wm(route='memory.query', args={\"query\": \"rust\", \"tags\": [\"project:myapp\"]}). Note: substring match, not ranked full-text — use memory.search for that.".into(),
2849            ("memory.vector.search", "memory_id") => "Provide a memory UUID for similarity search, e.g. wm(thought='find similar to <uuid>')".into(),
2850            ("memory.update", "id") => "Provide a memory UUID to update, e.g. wm(route='memory.update', args={\"id\": \"<uuid>\", \"tags\": [\"new\"]})".into(),
2851            ("memory.revisions", "id") => "Provide a memory UUID to inspect, e.g. wm(route='memory.revisions', args={\"id\": \"<uuid>\", \"action\": \"verify\"}) — actions: list (default) | verify".into(),
2852            ("memory.tag", "id") => "Provide a memory UUID to tag, e.g. wm(route='memory.tag', args={\"id\": \"<uuid>\", \"tags\": [\"rust\"]})".into(),
2853            _ => format!("Missing required argument: '{missing}' for tool '{tool_name}'"),
2854        }
2855    }
2856
2857    /// Extract payload from thought text by stripping routing keywords.
2858    fn extract_payload(thought: &str, tool_name: &str) -> Option<(String, String)> {
2859        let lower = thought.to_lowercase();
2860        match tool_name {
2861            "memory.create" => {
2862                for prefix in &[
2863                    "remember that ",
2864                    "remember ",
2865                    "store ",
2866                    "save ",
2867                    "note that ",
2868                    "note ",
2869                ] {
2870                    if lower.starts_with(prefix) {
2871                        let content = thought[prefix.len()..].to_string();
2872                        if !content.is_empty() {
2873                            return Some(("content".into(), content));
2874                        }
2875                    }
2876                }
2877                if !thought.is_empty() {
2878                    return Some(("content".into(), thought.to_string()));
2879                }
2880            }
2881            "memory.read" => {
2882                for prefix in &["recall ", "read memory ", "fetch memory ", "get memory "] {
2883                    if lower.starts_with(prefix) {
2884                        let id = thought[prefix.len()..].trim().to_string();
2885                        if !id.is_empty() {
2886                            return Some(("id".into(), id));
2887                        }
2888                    }
2889                }
2890            }
2891            "memory.list" => {
2892                for prefix in &[
2893                    "list memories",
2894                    "show memories",
2895                    "search memories",
2896                    "search for",
2897                ] {
2898                    if lower.contains(prefix) {
2899                        let after = &thought[lower.find(prefix).unwrap() + prefix.len()..];
2900                        let query = after.trim().trim_start_matches("in ").trim();
2901                        if !query.is_empty() {
2902                            return Some(("galaxy".into(), query.to_string()));
2903                        }
2904                    }
2905                }
2906            }
2907            "memory.delete" => {
2908                for prefix in &["delete memory ", "remove memory ", "forget memory "] {
2909                    if lower.starts_with(prefix) {
2910                        let id = thought[prefix.len()..].trim().to_string();
2911                        if !id.is_empty() {
2912                            return Some(("id".into(), id));
2913                        }
2914                    }
2915                }
2916            }
2917            "memory.search" => {
2918                for prefix in &["search for ", "search "] {
2919                    if lower.starts_with(prefix) {
2920                        let query = thought[prefix.len()..].trim().to_string();
2921                        if !query.is_empty() {
2922                            return Some(("query".into(), query));
2923                        }
2924                    }
2925                }
2926            }
2927            "memory.chat" => {
2928                for prefix in &[
2929                    "chat about ",
2930                    "chat ",
2931                    "ask about ",
2932                    "ask ",
2933                    "discuss ",
2934                    "explore ",
2935                    "converse about ",
2936                ] {
2937                    if lower.starts_with(prefix) {
2938                        let query = thought[prefix.len()..].trim().to_string();
2939                        if !query.is_empty() {
2940                            return Some(("query".into(), query));
2941                        }
2942                    }
2943                }
2944                if !thought.is_empty() {
2945                    return Some(("query".into(), thought.to_string()));
2946                }
2947            }
2948            "memory.vector.search" => {
2949                for prefix in &[
2950                    "find similar to ",
2951                    "similar to memory ",
2952                    "vector search ",
2953                    "semantic search ",
2954                    "embedding search ",
2955                ] {
2956                    if lower.starts_with(prefix) {
2957                        let id = thought[prefix.len()..].trim().to_string();
2958                        if !id.is_empty() {
2959                            return Some(("memory_id".into(), id));
2960                        }
2961                    }
2962                }
2963            }
2964            "memory.count" => {
2965                for prefix in &[
2966                    "count memories in ",
2967                    "how many memories in ",
2968                    "memory count ",
2969                ] {
2970                    if lower.starts_with(prefix) {
2971                        let galaxy = thought[prefix.len()..].trim().to_string();
2972                        if !galaxy.is_empty() {
2973                            return Some(("galaxy".into(), galaxy));
2974                        }
2975                    }
2976                }
2977            }
2978            "session.start" => {
2979                for prefix in &["start session ", "new session ", "begin session "] {
2980                    if lower.starts_with(prefix) {
2981                        let title = thought[prefix.len()..].trim().to_string();
2982                        if !title.is_empty() {
2983                            // `title` is the argument the session tool reads;
2984                            // the old payload key was "name", which silently
2985                            // created "Untitled Session" entries.
2986                            return Some(("title".into(), title));
2987                        }
2988                    }
2989                }
2990            }
2991            "session.end" => {
2992                for prefix in &["end session ", "close session ", "stop session "] {
2993                    if lower.starts_with(prefix) {
2994                        let id = thought[prefix.len()..].trim().to_string();
2995                        if !id.is_empty() {
2996                            return Some(("session_id".into(), id));
2997                        }
2998                    }
2999                }
3000            }
3001            "agent.register" => {
3002                for prefix in &[
3003                    "register agent ",
3004                    "new agent ",
3005                    "create agent ",
3006                    "add agent ",
3007                ] {
3008                    if lower.starts_with(prefix) {
3009                        let name = thought[prefix.len()..].trim().to_string();
3010                        if !name.is_empty() {
3011                            return Some(("name".into(), name));
3012                        }
3013                    }
3014                }
3015            }
3016            "agent.trust"
3017            | "agent.descriptions"
3018            | "agent.capabilities"
3019            | "agent.heartbeat.history"
3020            | "agent.deregister" => {
3021                for prefix in &[
3022                    "trust agent ",
3023                    "describe agent ",
3024                    "capabilities agent ",
3025                    "heartbeat history agent ",
3026                    "deregister agent ",
3027                    "unregister agent ",
3028                    "remove agent ",
3029                ] {
3030                    if lower.starts_with(prefix) {
3031                        let id = thought[prefix.len()..].trim().to_string();
3032                        if !id.is_empty() {
3033                            return Some(("agent_id".into(), id));
3034                        }
3035                    }
3036                }
3037            }
3038            "galaxy.purge" => {
3039                for prefix in &["purge galaxy ", "wipe galaxy ", "clear galaxy "] {
3040                    if lower.starts_with(prefix) {
3041                        let galaxy = thought[prefix.len()..].trim().to_string();
3042                        if !galaxy.is_empty() {
3043                            return Some(("galaxy".into(), galaxy));
3044                        }
3045                    }
3046                }
3047            }
3048            "task.distribute" => {
3049                for prefix in &["distribute task ", "assign task ", "dispatch task "] {
3050                    if lower.starts_with(prefix) {
3051                        let task = thought[prefix.len()..].trim().to_string();
3052                        if !task.is_empty() {
3053                            return Some(("task".into(), task));
3054                        }
3055                    }
3056                }
3057            }
3058            "memory.sort" => {
3059                for prefix in &["sort memories ", "sort memory ", "order memories "] {
3060                    if lower.starts_with(prefix) {
3061                        let galaxy = thought[prefix.len()..].trim().to_string();
3062                        if !galaxy.is_empty() {
3063                            return Some(("galaxy".into(), galaxy));
3064                        }
3065                    }
3066                }
3067            }
3068            "memory.filter" => {
3069                for prefix in &["filter memories ", "filter memory "] {
3070                    if lower.starts_with(prefix) {
3071                        let galaxy = thought[prefix.len()..].trim().to_string();
3072                        if !galaxy.is_empty() {
3073                            return Some(("galaxy".into(), galaxy));
3074                        }
3075                    }
3076                }
3077            }
3078            "memory.deduplicate" => {
3079                for prefix in &[
3080                    "deduplicate memories ",
3081                    "deduplicate memory ",
3082                    "dedup memories ",
3083                ] {
3084                    if lower.starts_with(prefix) {
3085                        let galaxy = thought[prefix.len()..].trim().to_string();
3086                        if !galaxy.is_empty() {
3087                            return Some(("galaxy".into(), galaxy));
3088                        }
3089                    }
3090                }
3091            }
3092            "memory.export" => {
3093                for prefix in &["export memories ", "export memory "] {
3094                    if lower.starts_with(prefix) {
3095                        let galaxy = thought[prefix.len()..].trim().to_string();
3096                        if !galaxy.is_empty() {
3097                            return Some(("galaxy".into(), galaxy));
3098                        }
3099                    }
3100                }
3101            }
3102            "speculative.decode" => {
3103                for prefix in &[
3104                    "speculative decode ",
3105                    "speculative ",
3106                    "decode ",
3107                    "draft and verify ",
3108                    "accelerate inference ",
3109                ] {
3110                    if lower.starts_with(prefix) {
3111                        let prompt = thought[prefix.len()..].trim().to_string();
3112                        if !prompt.is_empty() {
3113                            return Some(("prompt".into(), prompt));
3114                        }
3115                    }
3116                }
3117            }
3118            "meta.enhance" => {
3119                for prefix in &[
3120                    "enhance ",
3121                    "enhance prompt ",
3122                    "grounded inference ",
3123                    "self-correct ",
3124                    "meta enhance ",
3125                    "cognitive enhance ",
3126                    "augment ",
3127                ] {
3128                    if lower.starts_with(prefix) {
3129                        let prompt = thought[prefix.len()..].trim().to_string();
3130                        if !prompt.is_empty() {
3131                            return Some(("prompt".into(), prompt));
3132                        }
3133                    }
3134                }
3135            }
3136            "dense.encode" => {
3137                for prefix in &["dense encode ", "compress ", "encode ", "compact "] {
3138                    if lower.starts_with(prefix) {
3139                        let text = thought[prefix.len()..].trim().to_string();
3140                        if !text.is_empty() {
3141                            return Some(("text".into(), text));
3142                        }
3143                    }
3144                }
3145            }
3146            "dream.trigger" => {
3147                for prefix in &[
3148                    "dream trigger ",
3149                    "trigger dream ",
3150                    "start dream ",
3151                    "force dream ",
3152                    "initiate dream ",
3153                ] {
3154                    if lower.starts_with(prefix) {
3155                        let rest = thought[prefix.len()..].trim();
3156                        if !rest.is_empty() {
3157                            return Some(("force".into(), rest.to_string()));
3158                        }
3159                    }
3160                }
3161            }
3162            _ => {}
3163        }
3164        None
3165    }
3166}
3167
3168#[async_trait]
3169impl Tool for WmMetaTool {
3170    fn name(&self) -> &str {
3171        "wm"
3172    }
3173    fn gana(&self) -> Gana {
3174        Gana::Horn
3175    }
3176    fn effects(&self) -> &EffectRow {
3177        &self.effects
3178    }
3179    async fn call(&self, ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
3180        let thought = args.get("thought").and_then(|v| v.as_str()).unwrap_or("");
3181        let route = args.get("route").and_then(|v| v.as_str());
3182        let passthrough_args = args.get("args").cloned().unwrap_or(Value::Null);
3183
3184        if thought.is_empty() && route.is_none() {
3185            // Echo the keys we DID receive: when a client drops the routing
3186            // fields in transit, this turns a blind-spot error into an
3187            // immediate diagnosis (observed live 2026-08-23 — two requests
3188            // arrived with content/turn_type but no route, and the bare
3189            // message cost six probes to isolate).
3190            let received: Vec<String> = args
3191                .as_object()
3192                .map(|o| o.keys().cloned().collect())
3193                .unwrap_or_default();
3194            let detail = if received.is_empty() {
3195                String::new()
3196            } else {
3197                format!("; received argument keys: {received:?}")
3198            };
3199            return Ok(json!({
3200                "status": "error",
3201                "message": format!(
3202                    "Either 'thought' (natural language) or 'route' (explicit) is required{detail}"
3203                ),
3204                "hint": "wm(thought='remember that X is Y') or wm(route='memory.create', args={\"content\": \"...\"})"
3205            }));
3206        }
3207
3208        // Explicit routing
3209        let (tool_name, confidence, query_emb) = if let Some(r) = route {
3210            (r.to_string(), 1.0, None)
3211        } else {
3212            self.classify_async(thought).await
3213        };
3214
3215        // NLU abstention: when the router returns gnosis (the fallback) with
3216        // low confidence, the query didn't match any tool description well
3217        // enough. Rather than dispatch to the wrong tool, return an error
3218        // suggesting the user try explicit routing.
3219        if route.is_none() && tool_name == "gnosis" && confidence < NLU_ABSTENTION_THRESHOLD {
3220            return Ok(json!({
3221                "status": "error",
3222                "message": "Could not confidently match your request to a tool.",
3223                "confidence": confidence,
3224                "hint": "Use explicit routing: wm(route='tool.name', args={...}). Use wm(route='tools.list') to see available tools.",
3225                "_wm_route": { "tool": tool_name, "confidence": confidence, "abstained": true }
3226            }));
3227        }
3228
3229        // Build args for the target tool
3230        let mut tool_args = if passthrough_args.is_object() {
3231            // Strip _meta from passthrough args — _meta is a top-level MCP
3232            // request field, not a tool argument. Prevents untrusted callers
3233            // from injecting compartment/identity overrides via nested args.
3234            let mut args = passthrough_args;
3235            if let Some(obj) = args.as_object_mut() {
3236                obj.remove("_meta");
3237            }
3238            args
3239        } else {
3240            Value::Null
3241        };
3242
3243        // Auto-extract payload from thought when auto-routing
3244        if route.is_none() && !thought.is_empty() && tool_args.is_null() {
3245            if let Some((param, value)) = Self::extract_payload(thought, &tool_name) {
3246                tool_args = json!({ param: value });
3247            }
3248        }
3249
3250        // Look up the target tool.
3251        let tool = self.registry.get(&tool_name);
3252        match tool {
3253            Some(t) => {
3254                // Hard gate: destructive tools are unreachable via natural-language
3255                // routing — they require an explicit route= plus `confirm: true`,
3256                // which the dispatch pipeline enforces below. This makes it
3257                // structurally impossible for fuzzy NLU to destroy data.
3258                // This check fires BEFORE the required-arg check so the gate
3259                // message is always clear, even when args are missing.
3260                if route.is_none() && t.effects().destructive {
3261                    return Ok(json!({
3262                        "status": "error",
3263                        "message": format!(
3264                            "tool '{tool_name}' is destructive and cannot be reached via natural language — use wm(route='{tool_name}', args={{...}}) with \"confirm\": true"
3265                        ),
3266                        "_wm_route": { "tool": tool_name, "confidence": confidence },
3267                    }));
3268                }
3269
3270                // Check for missing required args before dispatching
3271                if let Some(required) = Self::required_arg(&tool_name) {
3272                    let has_arg = tool_args.is_object()
3273                        && tool_args.get(required).is_some()
3274                        && !tool_args
3275                            .get(required)
3276                            .is_some_and(serde_json::Value::is_null);
3277                    if !has_arg {
3278                        return Ok(json!({
3279                            "status": "error",
3280                            "message": format!("Missing required argument: '{required}' for tool '{tool_name}'"),
3281                            "hint": Self::missing_arg_hint(&tool_name, required),
3282                            "_wm_route": { "tool": tool_name, "confidence": confidence },
3283                        }));
3284                    }
3285                }
3286
3287                // Route through the full governance pipeline when attached:
3288                // destructive confirmation, dharma gate, rate limit, circuit
3289                // breaker, karma record, and per-tool stats all apply to the
3290                // inner tool. Falls back to a direct call when no pipeline is
3291                // attached (e.g. unit tests).
3292                let result = match &self.pipeline {
3293                    Some(p) => p.dispatch(t.as_ref(), ctx, tool_args).await,
3294                    None => t.call(ctx, tool_args).await,
3295                };
3296                // OATS: record routing outcome for embedding router refinement.
3297                // Reuse the query embedding computed during routing so the
3298                // embedder is called once per NLU request, not twice. When no
3299                // embedding is available (explicit route= or router fallback),
3300                // the re-embed does synchronous HTTP — run it on the blocking
3301                // pool instead of the tokio worker.
3302                if let Some(ref router) = self.embedding_router {
3303                    let success = result.is_ok();
3304                    if let Some(emb) = &query_emb {
3305                        router.record_outcome_with_embedding(&tool_name, thought, success, emb);
3306                    } else {
3307                        let router = Arc::clone(router);
3308                        let tool_name_owned = tool_name.clone();
3309                        let thought_owned = thought.to_string();
3310                        tokio::task::spawn_blocking(move || {
3311                            router.record_outcome(&tool_name_owned, &thought_owned, success);
3312                        });
3313                    }
3314                }
3315                match result {
3316                    Ok(mut output) => {
3317                        // Augment with routing metadata
3318                        if let Value::Object(ref mut map) = output {
3319                            map.insert(
3320                                "_wm_route".into(),
3321                                json!({
3322                                    "input": thought.chars().take(200).collect::<String>(),
3323                                    "tool": tool_name,
3324                                    "confidence": confidence,
3325                                }),
3326                            );
3327                        }
3328                        Ok(output)
3329                    }
3330                    Err(e) => Ok(json!({
3331                        "status": "error",
3332                        "error": e.to_string(),
3333                        "_wm_route": { "tool": tool_name, "confidence": confidence },
3334                    })),
3335                }
3336            }
3337            None => Ok(json!({
3338                "status": "error",
3339                "message": format!("Unknown tool: '{tool_name}'"),
3340                "_wm_route": { "tool": tool_name, "confidence": confidence },
3341            })),
3342        }
3343    }
3344    fn stats(&self) -> &ToolStats {
3345        &self.stats
3346    }
3347}
3348
3349// ── Helpers ──────────────────────────────────────────────────────────
3350
3351/// Parse a galaxy name string into a Galaxy enum.
3352fn parse_galaxy(s: &str) -> wm_core::Result<Galaxy> {
3353    expansion::common::parse_galaxy(s)
3354}
3355
3356/// Register all base tools into a registry.
3357///
3358/// `search`, `karma`, and `dharma` are optional — pass `None` if those
3359/// subsystems aren't available (e.g., no Tantivy index, no karma ledger).
3360/// `vector_store` is the in-memory vector index for embedding similarity search.
3361/// `conversational` is the optional N5 conversational search engine.
3362#[allow(clippy::too_many_arguments)]
3363pub fn register_all(
3364    registry: &ToolRegistry,
3365    store: &Arc<MemoryStore>,
3366    search: Option<Arc<SearchEngine>>,
3367    karma: Option<Arc<KarmaLedger>>,
3368    dharma: &Option<Arc<DharmaGate>>,
3369    substrate: Option<Arc<SubstrateMonitor>>,
3370    resource_rules: &Option<Arc<ResourceRules>>,
3371    associations: Arc<AssociationStore>,
3372    spiral_tracker: Arc<std::sync::Mutex<wm_cognitive::SpiralTracker>>,
3373    vector_store: Arc<std::sync::Mutex<VectorStore>>,
3374    conversational: Option<ConversationalSearch>,
3375    recall: Option<Arc<RecallEngine>>,
3376    homeostatic_loop: Option<Arc<std::sync::Mutex<HomeostaticLoop>>>,
3377    anomaly_detector: Option<Arc<std::sync::Mutex<AnomalyDetector>>>,
3378    sensorimotor_bus: Option<Arc<std::sync::Mutex<SensorimotorBus>>>,
3379    reflex_loop: Option<Arc<std::sync::Mutex<ReflexLoop>>>,
3380    gan_ying_bus: Option<&Arc<std::sync::Mutex<GanYingBus>>>,
3381    transaction_state: expansion::TransactionState,
3382    escalation_queue: Option<&Arc<std::sync::Mutex<wm_governance::EscalationQueue>>>,
3383    firewall: Option<&Arc<expansion::firewall::TxFirewall>>,
3384    code_graph: Option<&Arc<std::sync::Mutex<expansion::code::CodeGraph>>>,
3385) -> ToolRegistry {
3386    let mut reg = registry
3387        .register(Arc::new(MemoryCreateTool::new(
3388            store.clone(),
3389            search.clone(),
3390            recall.clone(),
3391        )))
3392        .register(Arc::new(MemoryBatchCreateTool::new(
3393            store.clone(),
3394            search.clone(),
3395            recall.clone(),
3396        )))
3397        .register(Arc::new(MemoryReadTool::new(store.clone())))
3398        .register(Arc::new(MemoryListTool::new(store.clone())))
3399        .register(Arc::new(MemoryDeleteTool::new(
3400            store.clone(),
3401            search.clone(),
3402        )))
3403        .register(Arc::new(MemoryBatchDeleteTool::new(
3404            store.clone(),
3405            search.clone(),
3406        )))
3407        .register(Arc::new(MemoryQueryTool::new(store.clone())))
3408        .register(Arc::new(MemoryAssociateTool::new(store.clone())))
3409        .register(Arc::new(MemoryAssociationsTool::new(store.clone())))
3410        .register(Arc::new(MemoryVectorSearchTool::new(
3411            store.clone(),
3412            vector_store,
3413        )))
3414        .register(Arc::new(GnosisTool::new(store.clone())));
3415
3416    if let Some(conv) = conversational {
3417        reg = reg.register(Arc::new(MemoryChatTool::new(conv)));
3418    }
3419
3420    if let Some(s) = search {
3421        // Public retrieval verb shares the hybrid implementation.
3422        // memory.hybrid_recall is registered as a compatibility alias
3423        // inside register_expansion.
3424        reg = reg.register(Arc::new(
3425            expansion::MemoryHybridRecallTool::as_search(
3426                store.clone(),
3427                Some(s.clone()),
3428                recall.clone(),
3429            )
3430            .with_associations(Some(associations.clone())),
3431        ));
3432        // Pass search to expansion tools
3433        reg = expansion::register_expansion(
3434            &reg,
3435            store,
3436            Some(s),
3437            recall,
3438            associations,
3439            spiral_tracker,
3440            karma.clone(),
3441            substrate.clone(),
3442            homeostatic_loop,
3443            anomaly_detector,
3444            sensorimotor_bus,
3445            reflex_loop,
3446            gan_ying_bus,
3447            transaction_state,
3448            resource_rules.as_ref(),
3449            escalation_queue,
3450            dharma.as_ref(),
3451            firewall,
3452            code_graph,
3453        );
3454    } else {
3455        reg = expansion::register_expansion(
3456            &reg,
3457            store,
3458            None,
3459            recall,
3460            associations,
3461            spiral_tracker,
3462            karma.clone(),
3463            substrate.clone(),
3464            homeostatic_loop,
3465            anomaly_detector,
3466            sensorimotor_bus,
3467            reflex_loop,
3468            gan_ying_bus,
3469            transaction_state,
3470            resource_rules.as_ref(),
3471            escalation_queue,
3472            dharma.as_ref(),
3473            firewall,
3474            code_graph,
3475        );
3476    }
3477    if let Some(k) = karma {
3478        reg = reg.register(Arc::new(KarmaReportTool::new(k)));
3479    }
3480    if let Some(d) = dharma {
3481        reg = reg.register(Arc::new(DharmaStatusTool::new(d.clone())));
3482    }
3483    if let Some(s) = substrate {
3484        reg = reg
3485            .register(Arc::new(HarmonyVectorTool::new(s.clone())))
3486            .register(Arc::new(HarmonyHistoryTool::new(s.clone())));
3487        if let Some(d) = dharma {
3488            if let Some(r) = resource_rules {
3489                reg = reg
3490                    .register(Arc::new(GnosisStatusTool::new(
3491                        d.clone(),
3492                        r.clone(),
3493                        s.clone(),
3494                    )))
3495                    .register(Arc::new(GnosisHistoryTool::new(s)))
3496                    .register(Arc::new(GnosisExplainTool::new(d.clone(), r.clone())));
3497            }
3498        }
3499    }
3500
3501    reg
3502}
3503
3504/// Register tools.list and wm meta-tool after the base tools are registered.
3505///
3506/// This requires a two-phase approach because tools.list needs the registry.
3507/// Also creates GnosisTool with registry access for brain-wave-aware tool counting.
3508/// The `shadow_stats` Arc is shared between the `WmMetaTool` and `NluShadowReportTool`.
3509pub fn register_meta_tools(
3510    registry: &ToolRegistry,
3511    store: &Arc<MemoryStore>,
3512    shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
3513) -> ToolRegistry {
3514    register_meta_tools_with_router(registry, store, shadow_stats, None).0
3515}
3516
3517/// Register the meta-tools and return the embedding router alongside.
3518///
3519/// The router is returned so the caller can persist/restore OATS outcome
3520/// stats (`save_oats` / `load_oats`) across restarts — the outcome-aware
3521/// refinement that makes NLU routing learn from dispatch outcomes.
3522///
3523/// When `pipeline` is `Some`, the `wm` meta-tool dispatches inner tools through
3524/// the full governance pipeline (destructive confirmation, dharma gate, rate
3525/// limit, circuit breaker, karma record, per-tool stats).
3526#[must_use]
3527pub fn register_meta_tools_with_router(
3528    registry: &ToolRegistry,
3529    store: &Arc<MemoryStore>,
3530    shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
3531    pipeline: Option<Arc<DispatchPipeline>>,
3532) -> (ToolRegistry, Option<Arc<embedding_router::EmbeddingRouter>>) {
3533    let base_snapshot: Vec<Arc<dyn Tool>> = registry.all();
3534    // Count includes old gnosis (which will be replaced with tool-count-aware version)
3535    let tool_count = base_snapshot.len();
3536
3537    let non_gnosis: Vec<Arc<dyn Tool>> = base_snapshot
3538        .iter()
3539        .filter(|t| t.name() != "gnosis")
3540        .cloned()
3541        .collect();
3542
3543    // Build tools.list with snapshot of non-gnosis tools
3544    let mut list_builder = ToolRegistryBuilder::new();
3545    for tool in &non_gnosis {
3546        list_builder.register(tool.clone());
3547    }
3548    let list_registry = Arc::new(list_builder.build());
3549    let tools_list = Arc::new(ToolsListTool::new(Arc::clone(&list_registry)));
3550
3551    // tools.usage_report shares the same registry snapshot — the tool Arcs
3552    // (and their ToolStats atomics) are shared across registries, so the
3553    // report reads the same counters the dispatch pipeline updates.
3554    let usage_report = Arc::new(expansion::ToolsUsageReportTool::new(list_registry));
3555
3556    // Build wm with all base tools (non-gnosis) + tools.list + new gnosis
3557    let gnosis = Arc::new(GnosisTool::with_tool_count(Arc::clone(store), tool_count));
3558    let mut wm_builder = ToolRegistryBuilder::new();
3559    for tool in &non_gnosis {
3560        wm_builder.register(tool.clone());
3561    }
3562    wm_builder.register(tools_list.clone());
3563    wm_builder.register(usage_report.clone());
3564    wm_builder.register(gnosis.clone());
3565
3566    // Create NLU shadow report tool sharing the same shadow stats.
3567    // Registered inside the wm meta-tool's routing registry so
3568    // `wm(route="nlu.shadow_report")` is reachable — the MCP boundary only
3569    // exposes the `wm` meta-tool, so top-level-only registration was unreachable.
3570    let shadow_report = Arc::new(expansion::NluShadowReportTool::new(Arc::clone(
3571        &shadow_stats,
3572    )));
3573    wm_builder.register(shadow_report.clone());
3574    let wm = Arc::new(WmMetaTool::with_router_shadow_stats_and_pipeline(
3575        Arc::new(wm_builder.build()),
3576        wm_memory::create_embedder(),
3577        shadow_stats,
3578        pipeline,
3579    ));
3580    let router = wm.embedding_router().cloned();
3581
3582    // Build the final registry: non-gnosis + tools.list + usage report + wm + gnosis + shadow report
3583    let mut final_builder = ToolRegistryBuilder::new();
3584    for tool in non_gnosis {
3585        final_builder.register(tool);
3586    }
3587    final_builder.register(tools_list);
3588    final_builder.register(usage_report);
3589    final_builder.register(wm);
3590    final_builder.register(gnosis);
3591    final_builder.register(shadow_report);
3592    (final_builder.build(), router)
3593}
3594
3595#[cfg(test)]
3596mod tests {
3597    use super::*;
3598    use wm_core::BrainWave;
3599
3600    fn test_store() -> Arc<MemoryStore> {
3601        let tmp = tempfile::tempdir().unwrap();
3602        Arc::new(MemoryStore::open_default(tmp.path()).unwrap())
3603    }
3604
3605    #[tokio::test]
3606    async fn memory_create_warns_on_credential_shaped_content() {
3607        let store = test_store();
3608        let tool = MemoryCreateTool::new(store, None, None);
3609        let mut ctx = Context::default();
3610
3611        let clean = tool
3612            .call(
3613                &mut ctx,
3614                json!({"content": "the password policy requires rotation"}),
3615            )
3616            .await
3617            .unwrap();
3618        assert!(clean.get("warnings").is_none(), "clean content: {clean}");
3619
3620        let flagged = tool
3621            .call(
3622                &mut ctx,
3623                json!({"content": "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----"}),
3624            )
3625            .await
3626            .unwrap();
3627        assert_eq!(
3628            flagged["status"], "success",
3629            "warning, not refusal: {flagged}"
3630        );
3631        let warnings = flagged["warnings"].as_array().unwrap();
3632        assert!(
3633            warnings[0].as_str().unwrap().contains("private_key_pem"),
3634            "got: {warnings:?}"
3635        );
3636        assert!(warnings[0].as_str().unwrap().contains("keyring"));
3637    }
3638
3639    #[tokio::test]
3640    async fn memory_batch_create_aggregates_credential_warnings() {
3641        let store = test_store();
3642        let tool = MemoryBatchCreateTool::new(store, None, None);
3643        let mut ctx = Context::default();
3644        let r = tool
3645            .call(
3646                &mut ctx,
3647                json!({"items": [
3648                    {"content": "ordinary note"},
3649                    {"content": "AKIAIOSFODNN7EXAMPLE"},
3650                ]}),
3651            )
3652            .await
3653            .unwrap();
3654        assert_eq!(r["count"], 2);
3655        let warnings = r["warnings"].as_array().unwrap();
3656        assert!(warnings[0].as_str().unwrap().contains("aws_access_key_id"));
3657    }
3658
3659    fn test_registry_with(store: &Arc<MemoryStore>) -> ToolRegistry {
3660        let registry = ToolRegistry::new();
3661        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3662        let spiral_tracker =
3663            Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
3664        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
3665        register_all(
3666            &registry,
3667            store,
3668            None,
3669            None,
3670            &None,
3671            None,
3672            &None,
3673            associations,
3674            spiral_tracker,
3675            vector_store,
3676            None,
3677            None,
3678            None,
3679            None,
3680            None,
3681            None,
3682            None,
3683            std::sync::Arc::new(std::sync::Mutex::new(None)),
3684            None,
3685            None,
3686            None,
3687        )
3688    }
3689
3690    #[tokio::test]
3691    async fn memory_create_and_read() {
3692        let store = test_store();
3693        let tool = MemoryCreateTool::new(store.clone(), None, None);
3694        let mut ctx = Context::new(BrainWave::Gamma);
3695
3696        let args = json!({"content": "test memory content", "galaxy": "codex"});
3697        let result = tool.call(&mut ctx, args).await.unwrap();
3698        assert_eq!(result["status"], "success");
3699        let id = result["id"].as_str().unwrap();
3700
3701        let read_tool = MemoryReadTool::new(store.clone());
3702        let result = read_tool.call(&mut ctx, json!({"id": id})).await.unwrap();
3703        assert_eq!(result["status"], "success");
3704        assert_eq!(result["content"], "test memory content");
3705
3706        let episodic = store
3707            .episodic()
3708            .get(uuid::Uuid::parse_str(id).unwrap())
3709            .unwrap()
3710            .expect("explicit memory writes mirror into episodic storage");
3711        assert_eq!(episodic.content, "test memory content");
3712    }
3713
3714    /// Track F Slice A: `attested` disclosure on memory.create. Fully
3715    /// hermetic — keys flow through the `with_attestation_key` seam, never
3716    /// the process environment (this crate forbids `unsafe`, and env
3717    /// mutation is `unsafe` in edition 2024).
3718    #[tokio::test]
3719    async fn memory_create_attestation_disclosure() {
3720        const TEST_KEY: &str = "0bd1c44170ca3d916648a983dcdb8583d22f2da5b29fdd5ede4b38e805435577";
3721        let mut ctx = Context::new(BrainWave::Gamma);
3722
3723        // Path 1: no key — honest negative, create still succeeds.
3724        let store = test_store();
3725        let tool = MemoryCreateTool::with_attestation_key(store.clone(), None, None, None);
3726        let result = tool
3727            .call(
3728                &mut ctx,
3729                json!({"content": "unattested create", "galaxy": "codex"}),
3730            )
3731            .await
3732            .unwrap();
3733        assert_eq!(result["status"], "success");
3734        assert_eq!(result["attested"], false);
3735        assert_eq!(result["attested_reason"], "node key unavailable");
3736
3737        // Path 2: invalid key material — honest negative, create succeeds.
3738        let tool = MemoryCreateTool::with_attestation_key(
3739            store.clone(),
3740            None,
3741            None,
3742            Some("not-hex".to_string()),
3743        );
3744        let result = tool
3745            .call(
3746                &mut ctx,
3747                json!({"content": "bad key create", "galaxy": "codex"}),
3748            )
3749            .await
3750            .unwrap();
3751        assert_eq!(result["attested"], false);
3752        assert_eq!(result["attested_reason"], "node key invalid");
3753
3754        // Path 3: key present — signed, stored, verifiable.
3755        let tool = MemoryCreateTool::with_attestation_key(
3756            store.clone(),
3757            None,
3758            None,
3759            Some(TEST_KEY.to_string()),
3760        );
3761        let result = tool
3762            .call(
3763                &mut ctx,
3764                json!({"content": "attested create", "galaxy": "codex"}),
3765            )
3766            .await
3767            .unwrap();
3768        assert_eq!(result["attested"], true);
3769        assert!(result.get("attested_reason").is_none());
3770        let id = uuid::Uuid::parse_str(result["id"].as_str().unwrap()).unwrap();
3771        let report = store.verify_attestation(Galaxy::Codex, id).unwrap();
3772        assert!(report.attested, "{:?}", report.breaks);
3773        assert!(report.signature_valid, "{:?}", report.breaks);
3774        assert!(report.matches_head, "{:?}", report.breaks);
3775        assert!(report.memory_present);
3776        assert!(report.breaks.is_empty());
3777
3778        // Stale path: rewrite the content out from under the attestation —
3779        // signature still verifies, head no longer matches (updates ride
3780        // the revisions chain, not re-attestation).
3781        let mut memory = store.get(Galaxy::Codex, id).unwrap().unwrap();
3782        memory.content = "edited after attestation".to_string();
3783        memory.metadata.content_hash = wm_memory::content_hash(&memory.content);
3784        store.put(Galaxy::Codex, &memory).unwrap();
3785        let stale = store.verify_attestation(Galaxy::Codex, id).unwrap();
3786        assert!(stale.attested);
3787        assert!(stale.signature_valid);
3788        assert!(!stale.matches_head);
3789
3790        // Scan sees exactly the one attested create.
3791        let scanned = store.scan_attestations().unwrap();
3792        assert_eq!(scanned.len(), 1);
3793        assert_eq!(scanned[0].memory_id, id.to_string());
3794    }
3795
3796    #[tokio::test]
3797    async fn memory_batch_create_attests_each_item() {
3798        const TEST_KEY: &str = "0bd1c44170ca3d916648a983dcdb8583d22f2da5b29fdd5ede4b38e805435577";
3799        let store = test_store();
3800        let tool = MemoryBatchCreateTool::with_attestation_key(
3801            store.clone(),
3802            None,
3803            None,
3804            Some(TEST_KEY.to_string()),
3805        );
3806        let mut ctx = Context::new(BrainWave::Gamma);
3807        let result = tool
3808            .call(
3809                &mut ctx,
3810                json!({"items": [{"content": "batch one"}, {"content": "batch two"}]}),
3811            )
3812            .await
3813            .unwrap();
3814        assert_eq!(result["attested_count"], 2);
3815        assert_eq!(store.scan_attestations().unwrap().len(), 2);
3816
3817        // Keyless batch: zero attested, creates still succeed.
3818        let tool = MemoryBatchCreateTool::with_attestation_key(store.clone(), None, None, None);
3819        let result = tool
3820            .call(&mut ctx, json!({"items": [{"content": "batch three"}]}))
3821            .await
3822            .unwrap();
3823        assert_eq!(result["attested_count"], 0);
3824        assert_eq!(result["count"], 1);
3825    }
3826
3827    #[tokio::test]
3828    async fn memory_batch_create_mirrors_into_episodic_lane() {
3829        let store = test_store();
3830        let tool = MemoryBatchCreateTool::new(store.clone(), None, None);
3831        let mut ctx = Context::new(BrainWave::Gamma);
3832        let result = tool
3833            .call(
3834                &mut ctx,
3835                json!({
3836                    "items": [
3837                        {"content": "batch rust retrieval"},
3838                        {"content": "batch grocery list"}
3839                    ]
3840                }),
3841            )
3842            .await
3843            .unwrap();
3844        assert_eq!(result["status"], "success");
3845        let ids = result["ids"].as_array().unwrap();
3846        let first = uuid::Uuid::parse_str(ids[0].as_str().unwrap()).unwrap();
3847        let hits = store
3848            .episodic()
3849            .search("rust retrieval", 10, false)
3850            .unwrap();
3851        assert_eq!(hits.len(), 1);
3852        assert_eq!(hits[0].record.id, first);
3853    }
3854
3855    #[tokio::test]
3856    async fn memory_list_returns_entries() {
3857        let store = test_store();
3858        let create = MemoryCreateTool::new(store.clone(), None, None);
3859        let mut ctx = Context::new(BrainWave::Gamma);
3860
3861        for i in 0..3 {
3862            create
3863                .call(&mut ctx, json!({"content": format!("item-{i}")}))
3864                .await
3865                .unwrap();
3866        }
3867
3868        let list = MemoryListTool::new(store);
3869        let result = list.call(&mut ctx, json!({"limit": 10})).await.unwrap();
3870        assert_eq!(result["status"], "success");
3871        assert_eq!(result["total"], 3);
3872        assert_eq!(result["returned"], 3);
3873    }
3874
3875    /// API honesty (§8): `offset` and `exclude_tags` are real. Paging
3876    /// addresses the VISIBLE surface — private memories and excluded tags
3877    /// never consume page slots.
3878    #[tokio::test]
3879    async fn memory_list_offset_and_exclude_tags_page_visible_surface() {
3880        let store = test_store();
3881        let mut ctx = Context::new(BrainWave::Gamma);
3882
3883        for i in 0..5 {
3884            let mut m = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("page note {i}"));
3885            if i == 1 {
3886                m.metadata.tags = vec!["noise".into()];
3887            }
3888            if i == 3 {
3889                m.metadata.is_private = true;
3890            }
3891            store.put(wm_core::Galaxy::Codex, &m).unwrap();
3892        }
3893
3894        let list = MemoryListTool::new(store);
3895
3896        // Baseline: private memory and the excluded tag drop out of the
3897        // visible surface; the response discloses matched vs returned.
3898        let all = list
3899            .call(
3900                &mut ctx,
3901                json!({"galaxy": "codex", "limit": 50, "exclude_tags": ["noise"]}),
3902            )
3903            .await
3904            .unwrap();
3905        assert_eq!(all["total"], 5, "total counts the whole galaxy");
3906        assert_eq!(all["matched"], 3, "private + excluded are invisible");
3907        assert_eq!(all["returned"], 3);
3908        assert_eq!(all["offset"], 0);
3909
3910        // Page 1 + page 2 partition the visible surface without overlap.
3911        let page1 = list
3912            .call(
3913                &mut ctx,
3914                json!({"galaxy": "codex", "limit": 2, "offset": 0, "exclude_tags": ["noise"]}),
3915            )
3916            .await
3917            .unwrap();
3918        assert_eq!(page1["returned"], 2);
3919        let page2 = list
3920            .call(
3921                &mut ctx,
3922                json!({"galaxy": "codex", "limit": 2, "offset": 2, "exclude_tags": ["noise"]}),
3923            )
3924            .await
3925            .unwrap();
3926        assert_eq!(
3927            page2["returned"], 1,
3928            "matched is 3 — the tail page is short"
3929        );
3930        assert_eq!(page2["offset"], 2);
3931
3932        let ids_of = |v: &Value| -> Vec<String> {
3933            v["memories"]
3934                .as_array()
3935                .unwrap()
3936                .iter()
3937                .filter_map(|m| m["id"].as_str().map(String::from))
3938                .collect()
3939        };
3940        let (p1, p2, everything) = (ids_of(&page1), ids_of(&page2), ids_of(&all));
3941        assert_eq!(p1.len(), 2);
3942        let mut union = p1;
3943        union.extend(p2);
3944        let mut sorted_union = union.clone();
3945        sorted_union.sort();
3946        let mut sorted_all = everything;
3947        sorted_all.sort();
3948        assert_eq!(sorted_union, sorted_all, "pages must partition the surface");
3949    }
3950
3951    /// Provenance contract (sessions-galaxy attribution fix, 2026-08-29):
3952    /// memory.create defaults to agent/0.7 — a "user" claim must be
3953    /// deliberate, and trust is derived from the claimed class, never
3954    /// caller-chosen.
3955    #[tokio::test]
3956    async fn memory_create_stamps_provenance_by_claim() {
3957        let store = test_store();
3958        let create = MemoryCreateTool::new(store.clone(), None, None);
3959        let mut ctx = Context::new(BrainWave::Gamma);
3960
3961        let silent = create
3962            .call(&mut ctx, json!({"content": "no claim"}))
3963            .await
3964            .unwrap();
3965        assert_eq!(silent["source"], "agent");
3966        assert!((silent["source_trust"].as_f64().unwrap() - 0.7).abs() < 1e-5);
3967
3968        let claimed = create
3969            .call(
3970                &mut ctx,
3971                json!({"content": "user dictated this", "source": "user"}),
3972            )
3973            .await
3974            .unwrap();
3975        assert_eq!(claimed["source"], "user");
3976        assert!((claimed["source_trust"].as_f64().unwrap() - 1.0).abs() < 1e-5);
3977
3978        let custom = create
3979            .call(&mut ctx, json!({"content": "web import", "source": "web"}))
3980            .await
3981            .unwrap();
3982        assert_eq!(custom["source"], "web");
3983        assert!((custom["source_trust"].as_f64().unwrap() - 0.7).abs() < 1e-5);
3984
3985        let fetch = |id: &str| {
3986            store
3987                .get(wm_core::Galaxy::Codex, uuid::Uuid::parse_str(id).unwrap())
3988                .expect("stored")
3989                .expect("present")
3990        };
3991        assert_eq!(
3992            fetch(silent["id"].as_str().unwrap()).metadata.source,
3993            "agent"
3994        );
3995        assert_eq!(
3996            fetch(claimed["id"].as_str().unwrap()).metadata.source,
3997            "user"
3998        );
3999    }
4000
4001    #[tokio::test]
4002    async fn gnosis_returns_system_info() {
4003        let store = test_store();
4004        let tool = GnosisTool::new(store);
4005        let mut ctx = Context::new(BrainWave::Gamma);
4006        let result = tool.call(&mut ctx, json!({})).await.unwrap();
4007        assert_eq!(result["status"], "success");
4008        assert!(result["version"].is_string());
4009    }
4010
4011    #[tokio::test]
4012    async fn memory_delete_removes_entry() {
4013        let store = test_store();
4014        let create = MemoryCreateTool::new(store.clone(), None, None);
4015        let mut ctx = Context::new(BrainWave::Gamma);
4016
4017        let result = create
4018            .call(&mut ctx, json!({"content": "to be deleted"}))
4019            .await
4020            .unwrap();
4021        let id = result["id"].as_str().unwrap();
4022
4023        let delete = MemoryDeleteTool::new(store.clone(), None);
4024        let result = delete.call(&mut ctx, json!({"id": id})).await.unwrap();
4025        assert_eq!(result["status"], "success");
4026
4027        let read = MemoryReadTool::new(store);
4028        let result = read.call(&mut ctx, json!({"id": id})).await.unwrap();
4029        assert_eq!(result["status"], "not_found");
4030    }
4031
4032    #[tokio::test]
4033    async fn memory_delete_without_galaxy_resolves_across_memory_galaxies() {
4034        let store = test_store();
4035        let create = MemoryCreateTool::new(store.clone(), None, None);
4036        let mut ctx = Context::new(BrainWave::Gamma);
4037
4038        // A session memory lives in the sessions galaxy, not codex.
4039        let result = create
4040            .call(
4041                &mut ctx,
4042                json!({"content": "session decision", "galaxy": "sessions"}),
4043            )
4044            .await
4045            .unwrap();
4046        let id = result["id"].as_str().unwrap();
4047
4048        // No explicit galaxy: the delete must still find and remove it.
4049        let delete = MemoryDeleteTool::new(store.clone(), None);
4050        let result = delete.call(&mut ctx, json!({"id": id})).await.unwrap();
4051        assert_eq!(result["status"], "success");
4052        assert!(
4053            result["galaxies"]
4054                .as_array()
4055                .unwrap()
4056                .contains(&json!("sessions"))
4057        );
4058
4059        let read = MemoryReadTool::new(store.clone());
4060        let result = read
4061            .call(&mut ctx, json!({"id": id, "galaxy": "sessions"}))
4062            .await
4063            .unwrap();
4064        assert_eq!(result["status"], "not_found");
4065    }
4066
4067    #[tokio::test]
4068    async fn memory_delete_explicit_galaxy_does_not_miss_other_galaxies() {
4069        let store = test_store();
4070        let create = MemoryCreateTool::new(store.clone(), None, None);
4071        let mut ctx = Context::new(BrainWave::Gamma);
4072
4073        let result = create
4074            .call(
4075                &mut ctx,
4076                json!({"content": "in sessions", "galaxy": "sessions"}),
4077            )
4078            .await
4079            .unwrap();
4080        let id = result["id"].as_str().unwrap();
4081
4082        // Explicit wrong galaxy: truthful not_found with a hint, record intact.
4083        let delete = MemoryDeleteTool::new(store.clone(), None);
4084        let result = delete
4085            .call(&mut ctx, json!({"id": id, "galaxy": "codex"}))
4086            .await
4087            .unwrap();
4088        assert_eq!(result["status"], "not_found");
4089        assert!(result["hint"].is_string());
4090
4091        let read = MemoryReadTool::new(store.clone());
4092        let result = read
4093            .call(&mut ctx, json!({"id": id, "galaxy": "sessions"}))
4094            .await
4095            .unwrap();
4096        assert_eq!(result["status"], "success");
4097    }
4098
4099    #[tokio::test]
4100    async fn memory_query_filters_by_tags() {
4101        let store = test_store();
4102        let create = MemoryCreateTool::new(store.clone(), None, None);
4103        let mut ctx = Context::new(BrainWave::Gamma);
4104
4105        create
4106            .call(&mut ctx, json!({"content": "tagged", "tags": ["rust"]}))
4107            .await
4108            .unwrap();
4109        create
4110            .call(&mut ctx, json!({"content": "untagged"}))
4111            .await
4112            .unwrap();
4113
4114        let query = MemoryQueryTool::new(store);
4115        let result = query
4116            .call(&mut ctx, json!({"tags": ["rust"]}))
4117            .await
4118            .unwrap();
4119        assert_eq!(result["status"], "success");
4120        assert_eq!(result["total"], 1);
4121    }
4122
4123    /// API honesty (§8): `created_after` / `created_before` pass through to
4124    /// the store's temporal filter instead of being silently ignored.
4125    #[tokio::test]
4126    async fn memory_query_time_range_passthrough() {
4127        let store = test_store();
4128        let mut ctx = Context::new(BrainWave::Gamma);
4129
4130        let mut old = wm_memory::Memory::new(wm_core::Galaxy::Codex, "old relic".into());
4131        old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
4132        store.put(wm_core::Galaxy::Codex, &old).unwrap();
4133        let mut recent = wm_memory::Memory::new(wm_core::Galaxy::Codex, "recent note".into());
4134        recent.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
4135        store.put(wm_core::Galaxy::Codex, &recent).unwrap();
4136
4137        let query = MemoryQueryTool::new(store);
4138        let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
4139            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
4140
4141        let only_recent = query
4142            .call(&mut ctx, json!({"created_after": cutoff}))
4143            .await
4144            .unwrap();
4145        assert_eq!(only_recent["total"], 1);
4146        assert_eq!(only_recent["memories"][0]["content_preview"], "recent note");
4147        assert_eq!(
4148            only_recent["time_range"]["created_after"], cutoff,
4149            "the applied time range must be disclosed"
4150        );
4151
4152        let only_old = query
4153            .call(&mut ctx, json!({"created_before": cutoff}))
4154            .await
4155            .unwrap();
4156        assert_eq!(only_old["total"], 1);
4157        assert_eq!(only_old["memories"][0]["content_preview"], "old relic");
4158
4159        // Both bounds compose.
4160        let both = query
4161            .call(
4162                &mut ctx,
4163                json!({
4164                    "created_after": (chrono::Utc::now() - chrono::Duration::days(90)).to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4165                    "created_before": cutoff,
4166                }),
4167            )
4168            .await
4169            .unwrap();
4170        assert_eq!(both["total"], 1);
4171        assert_eq!(both["memories"][0]["content_preview"], "old relic");
4172
4173        // Malformed bounds are a loud InvalidArgs, never a silent no-filter.
4174        let bad = query
4175            .call(&mut ctx, json!({"created_after": "not-a-timestamp"}))
4176            .await;
4177        assert!(bad.is_err(), "invalid RFC 3339 must be refused");
4178    }
4179
4180    #[tokio::test]
4181    async fn memory_vector_search_by_embedding() {
4182        let store = test_store();
4183        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4184
4185        // Add some vectors directly
4186        {
4187            let mut vs = vector_store.lock().unwrap();
4188            vs.add(uuid::Uuid::new_v4(), Galaxy::Codex, vec![1.0, 0.0, 0.0]);
4189            vs.add(uuid::Uuid::new_v4(), Galaxy::Codex, vec![0.9, 0.1, 0.0]);
4190            vs.add(uuid::Uuid::new_v4(), Galaxy::Research, vec![0.0, 1.0, 0.0]);
4191        }
4192
4193        let tool = MemoryVectorSearchTool::new(store, vector_store);
4194        let mut ctx = Context::new(BrainWave::Gamma);
4195
4196        // Search for vectors similar to [1, 0, 0]
4197        let result = tool
4198            .call(&mut ctx, json!({"embedding": [1.0, 0.0, 0.0], "limit": 2}))
4199            .await
4200            .unwrap();
4201        assert_eq!(result["status"], "success");
4202        assert_eq!(result["total"], 2);
4203    }
4204
4205    #[tokio::test]
4206    async fn memory_vector_search_missing_args() {
4207        let store = test_store();
4208        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4209
4210        let tool = MemoryVectorSearchTool::new(store, vector_store);
4211        let mut ctx = Context::new(BrainWave::Gamma);
4212
4213        let result = tool.call(&mut ctx, json!({"limit": 5})).await;
4214        assert!(result.is_err());
4215    }
4216
4217    #[tokio::test]
4218    async fn wm_routes_vector_search_to_memory_vector_search() {
4219        let store = test_store();
4220        let registry = test_registry_with(&store);
4221        let registry = register_meta_tools(
4222            &registry,
4223            &store,
4224            std::sync::Arc::new(std::sync::RwLock::new(
4225                embedding_router::ShadowModeStats::default(),
4226            )),
4227        );
4228
4229        let wm = registry.get("wm").unwrap();
4230        let mut ctx = Context::new(BrainWave::Gamma);
4231        let result = wm
4232            .call(
4233                &mut ctx,
4234                json!({"route": "memory.vector.search", "args": {"embedding": [1.0, 0.0, 0.0]}}),
4235            )
4236            .await
4237            .unwrap();
4238
4239        assert_eq!(result["status"], "success");
4240        assert_eq!(result["_wm_route"]["tool"], "memory.vector.search");
4241    }
4242
4243    #[tokio::test]
4244    async fn wm_routes_shadow_report_inside_meta_tool() {
4245        // The MCP boundary only exposes the `wm` meta-tool, so observability
4246        // tools must be reachable through it. Regression test: `nlu.shadow_report`
4247        // was top-level-only and returned "Unknown tool" via wm(route=...).
4248        let store = test_store();
4249        let registry = test_registry_with(&store);
4250        let registry = register_meta_tools(
4251            &registry,
4252            &store,
4253            std::sync::Arc::new(std::sync::RwLock::new(
4254                embedding_router::ShadowModeStats::default(),
4255            )),
4256        );
4257
4258        let wm = registry.get("wm").unwrap();
4259        let mut ctx = Context::new(BrainWave::Gamma);
4260        let result = wm
4261            .call(&mut ctx, json!({"route": "nlu.shadow_report"}))
4262            .await
4263            .unwrap();
4264
4265        assert_eq!(result["_wm_route"]["tool"], "nlu.shadow_report");
4266        assert!(
4267            result.get("total_queries").is_some(),
4268            "expected shadow report payload"
4269        );
4270    }
4271
4272    #[tokio::test]
4273    async fn memory_associate_and_find() {
4274        let store = test_store();
4275        let create = MemoryCreateTool::new(store.clone(), None, None);
4276        let mut ctx = Context::new(BrainWave::Gamma);
4277
4278        let r1 = create
4279            .call(&mut ctx, json!({"content": "source mem"}))
4280            .await
4281            .unwrap();
4282        let r2 = create
4283            .call(&mut ctx, json!({"content": "target mem"}))
4284            .await
4285            .unwrap();
4286        let id1 = r1["id"].as_str().unwrap();
4287        let id2 = r2["id"].as_str().unwrap();
4288
4289        let assoc = MemoryAssociateTool::new(store.clone());
4290        let result = assoc
4291            .call(
4292                &mut ctx,
4293                json!({"source": id1, "target": id2, "weight": 0.8}),
4294            )
4295            .await
4296            .unwrap();
4297        assert_eq!(result["status"], "success");
4298
4299        let find = MemoryAssociationsTool::new(store);
4300        let result = find
4301            .call(&mut ctx, json!({"id": id1, "direction": "from"}))
4302            .await
4303            .unwrap();
4304        assert_eq!(result["status"], "success");
4305        assert_eq!(result["returned"], 1);
4306    }
4307
4308    #[tokio::test]
4309    async fn karma_report_shows_entries() {
4310        let tmp = tempfile::tempdir().unwrap();
4311        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
4312        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
4313
4314        // Record a few entries
4315        ledger.record("test_tool", false, 0, true).unwrap();
4316        ledger.record("wasteful_tool", true, 0, true).unwrap();
4317
4318        let tool = KarmaReportTool::new(ledger);
4319        let mut ctx = Context::new(BrainWave::Gamma);
4320        let result = tool.call(&mut ctx, json!({"limit": 5})).await.unwrap();
4321        assert_eq!(result["status"], "success");
4322        assert_eq!(result["entry_count"], 2);
4323        assert_eq!(result["recent_entries"].as_array().unwrap().len(), 2);
4324    }
4325
4326    #[tokio::test]
4327    async fn dharma_status_returns_homeostasis() {
4328        let gate = Arc::new(DharmaGate::default());
4329        let tool = DharmaStatusTool::new(gate);
4330        let mut ctx = Context::new(BrainWave::Gamma);
4331        let result = tool.call(&mut ctx, json!({})).await.unwrap();
4332        assert_eq!(result["status"], "success");
4333        assert!(result["homeostasis"]["health_score"].is_f64());
4334        assert!(result["sutras"]["ahimsa"].is_string());
4335    }
4336
4337    #[tokio::test]
4338    async fn wm_routes_remember_to_memory_create() {
4339        let store = test_store();
4340        let registry = test_registry_with(&store);
4341        let registry = register_meta_tools(
4342            &registry,
4343            &store,
4344            std::sync::Arc::new(std::sync::RwLock::new(
4345                embedding_router::ShadowModeStats::default(),
4346            )),
4347        );
4348
4349        let wm = registry.get("wm").unwrap();
4350        let mut ctx = Context::new(BrainWave::Gamma);
4351        let result = wm
4352            .call(
4353                &mut ctx,
4354                json!({"thought": "remember that the API uses X-User-Id headers"}),
4355            )
4356            .await
4357            .unwrap();
4358
4359        assert_eq!(result["status"], "success");
4360        assert_eq!(result["_wm_route"]["tool"], "memory.create");
4361        assert!(result["id"].is_string());
4362    }
4363
4364    #[tokio::test]
4365    async fn wm_explicit_route() {
4366        let store = test_store();
4367        let registry = test_registry_with(&store);
4368        let registry = register_meta_tools(
4369            &registry,
4370            &store,
4371            std::sync::Arc::new(std::sync::RwLock::new(
4372                embedding_router::ShadowModeStats::default(),
4373            )),
4374        );
4375
4376        let wm = registry.get("wm").unwrap();
4377        let mut ctx = Context::new(BrainWave::Gamma);
4378        let result = wm
4379            .call(
4380                &mut ctx,
4381                json!({
4382                    "route": "gnosis"
4383                }),
4384            )
4385            .await
4386            .unwrap();
4387
4388        assert_eq!(result["status"], "success");
4389        assert_eq!(result["_wm_route"]["tool"], "gnosis");
4390    }
4391
4392    #[tokio::test]
4393    async fn wm_no_input_returns_error() {
4394        let store = test_store();
4395        let registry = test_registry_with(&store);
4396        let registry = register_meta_tools(
4397            &registry,
4398            &store,
4399            std::sync::Arc::new(std::sync::RwLock::new(
4400                embedding_router::ShadowModeStats::default(),
4401            )),
4402        );
4403
4404        let wm = registry.get("wm").unwrap();
4405        let mut ctx = Context::new(BrainWave::Gamma);
4406        let result = wm.call(&mut ctx, json!({})).await.unwrap();
4407
4408        assert_eq!(result["status"], "error");
4409    }
4410
4411    #[tokio::test]
4412    async fn wm_missing_route_echoes_received_keys() {
4413        // When a client drops the routing fields in transit, the error must
4414        // show which keys DID arrive so the drop is diagnosable in one step
4415        // (observed live 2026-08-23: two requests arrived with payload keys
4416        // but no route; the bare message cost six probes to isolate).
4417        let store = test_store();
4418        let registry = test_registry_with(&store);
4419        let registry = register_meta_tools(
4420            &registry,
4421            &store,
4422            std::sync::Arc::new(std::sync::RwLock::new(
4423                embedding_router::ShadowModeStats::default(),
4424            )),
4425        );
4426
4427        let wm = registry.get("wm").unwrap();
4428        let mut ctx = Context::new(BrainWave::Gamma);
4429        let result = wm
4430            .call(
4431                &mut ctx,
4432                json!({"content": "x", "turn_type": "summary", "importance": 0.5}),
4433            )
4434            .await
4435            .unwrap();
4436
4437        assert_eq!(result["status"], "error");
4438        let message = result["message"].as_str().unwrap();
4439        assert!(
4440            message.contains("received argument keys"),
4441            "error must disclose received keys, got: {message}"
4442        );
4443        for key in ["content", "turn_type", "importance"] {
4444            assert!(
4445                message.contains(key),
4446                "error must list received key '{key}', got: {message}"
4447            );
4448        }
4449        // Empty-input case stays bare (no keys to list).
4450        let empty = wm.call(&mut ctx, json!({})).await.unwrap();
4451        assert!(
4452            !empty["message"]
4453                .as_str()
4454                .unwrap()
4455                .contains("received argument keys: ["),
4456            "empty input must not list keys, got: {}",
4457            empty["message"]
4458        );
4459    }
4460
4461    #[tokio::test]
4462    async fn wm_unknown_tool_returns_error() {
4463        let store = test_store();
4464        let registry = test_registry_with(&store);
4465        let registry = register_meta_tools(
4466            &registry,
4467            &store,
4468            std::sync::Arc::new(std::sync::RwLock::new(
4469                embedding_router::ShadowModeStats::default(),
4470            )),
4471        );
4472
4473        let wm = registry.get("wm").unwrap();
4474        let mut ctx = Context::new(BrainWave::Gamma);
4475        let result = wm
4476            .call(&mut ctx, json!({"route": "nonexistent.tool"}))
4477            .await
4478            .unwrap();
4479
4480        assert_eq!(result["status"], "error");
4481        assert!(result["message"].as_str().unwrap().contains("Unknown tool"));
4482    }
4483
4484    #[tokio::test]
4485    async fn wm_missing_arg_returns_hint() {
4486        let store = test_store();
4487        let registry = test_registry_with(&store);
4488        let registry = register_meta_tools(
4489            &registry,
4490            &store,
4491            std::sync::Arc::new(std::sync::RwLock::new(
4492                embedding_router::ShadowModeStats::default(),
4493            )),
4494        );
4495
4496        let wm = registry.get("wm").unwrap();
4497        let mut ctx = Context::new(BrainWave::Gamma);
4498
4499        // Route to memory.read without providing id
4500        let result = wm
4501            .call(&mut ctx, json!({"route": "memory.read"}))
4502            .await
4503            .unwrap();
4504
4505        assert_eq!(result["status"], "error");
4506        assert!(
4507            result["message"]
4508                .as_str()
4509                .unwrap()
4510                .contains("Missing required argument")
4511        );
4512        assert!(result["hint"].as_str().unwrap().contains("uuid"));
4513    }
4514
4515    #[tokio::test]
4516    async fn wm_auto_route_missing_arg_returns_hint() {
4517        let store = test_store();
4518        let registry = test_registry_with(&store);
4519        let registry = register_meta_tools(
4520            &registry,
4521            &store,
4522            std::sync::Arc::new(std::sync::RwLock::new(
4523                embedding_router::ShadowModeStats::default(),
4524            )),
4525        );
4526
4527        let wm = registry.get("wm").unwrap();
4528        let mut ctx = Context::new(BrainWave::Gamma);
4529
4530        // "recall" routes to memory.read but no UUID provided
4531        let result = wm
4532            .call(&mut ctx, json!({"thought": "recall"}))
4533            .await
4534            .unwrap();
4535
4536        assert_eq!(result["status"], "error");
4537        assert!(result["hint"].as_str().unwrap().contains("uuid"));
4538    }
4539
4540    #[tokio::test]
4541    async fn wm_routes_karma_to_karma_report() {
4542        let tmp = tempfile::tempdir().unwrap();
4543        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
4544        let ledger = Arc::new(KarmaLedger::new(store.clone()).unwrap());
4545        let gate = Arc::new(DharmaGate::default());
4546
4547        let registry = ToolRegistry::new();
4548        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
4549        let spiral_tracker =
4550            Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
4551        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4552        let registry = register_all(
4553            &registry,
4554            &store,
4555            None,
4556            Some(ledger),
4557            &Some(gate),
4558            None,
4559            &None,
4560            associations,
4561            spiral_tracker,
4562            vector_store,
4563            None,
4564            None,
4565            None,
4566            None,
4567            None,
4568            None,
4569            None,
4570            std::sync::Arc::new(std::sync::Mutex::new(None)),
4571            None,
4572            None,
4573            None,
4574        );
4575        let registry = register_meta_tools(
4576            &registry,
4577            &store,
4578            std::sync::Arc::new(std::sync::RwLock::new(
4579                embedding_router::ShadowModeStats::default(),
4580            )),
4581        );
4582
4583        let wm = registry.get("wm").unwrap();
4584        let mut ctx = Context::new(BrainWave::Gamma);
4585        let result = wm
4586            .call(&mut ctx, json!({"thought": "show me the karma report"}))
4587            .await
4588            .unwrap();
4589
4590        assert_eq!(result["status"], "success");
4591        assert_eq!(result["_wm_route"]["tool"], "karma.report");
4592    }
4593
4594    /// Build a registry with the wm meta-tool wired to a real DispatchPipeline,
4595    /// so inner tool calls are governance-gated (destructive confirm, etc.).
4596    fn test_registry_with_pipeline(
4597        store: &Arc<MemoryStore>,
4598    ) -> (ToolRegistry, Arc<DispatchPipeline>) {
4599        let registry = test_registry_with(store);
4600        let pipeline = Arc::new(DispatchPipeline::with_defaults());
4601        let (registry, _router) = register_meta_tools_with_router(
4602            &registry,
4603            store,
4604            std::sync::Arc::new(std::sync::RwLock::new(
4605                embedding_router::ShadowModeStats::default(),
4606            )),
4607            Some(pipeline.clone()),
4608        );
4609        (registry, pipeline)
4610    }
4611
4612    #[tokio::test]
4613    async fn wm_route_destructive_without_confirm_blocked_by_pipeline() {
4614        let store = test_store();
4615        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4616
4617        let wm = registry.get("wm").unwrap();
4618        let mut ctx = Context::new(BrainWave::Gamma);
4619        let result = wm
4620            .call(
4621                &mut ctx,
4622                json!({"route": "memory.delete", "args": {"id": "00000000-0000-0000-0000-000000000001"}}),
4623            )
4624            .await
4625            .unwrap();
4626
4627        assert_eq!(result["status"], "error");
4628        assert!(
4629            result["error"].as_str().unwrap().contains("destructive"),
4630            "expected destructive-gate message, got: {result}"
4631        );
4632        assert!(result["error"].as_str().unwrap().contains("confirm"));
4633    }
4634
4635    #[tokio::test]
4636    async fn wm_route_destructive_with_confirm_proceeds() {
4637        let store = test_store();
4638        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4639
4640        // Create a real memory to delete.
4641        let memory = Memory::new(Galaxy::Codex, "delete me via wm route".into());
4642        let id = memory.metadata.id;
4643        store.put(Galaxy::Codex, &memory).unwrap();
4644
4645        let wm = registry.get("wm").unwrap();
4646        let mut ctx = Context::new(BrainWave::Gamma);
4647        let result = wm
4648            .call(
4649                &mut ctx,
4650                json!({"route": "memory.delete", "args": {"id": id.to_string(), "galaxy": "codex", "confirm": true}}),
4651            )
4652            .await
4653            .unwrap();
4654
4655        assert_eq!(result["status"], "success");
4656        assert_eq!(result["_wm_route"]["tool"], "memory.delete");
4657        assert!(store.get(Galaxy::Codex, id).unwrap().is_none());
4658    }
4659
4660    #[tokio::test]
4661    async fn wm_thought_cannot_reach_destructive_tool() {
4662        let store = test_store();
4663        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4664
4665        let wm = registry.get("wm").unwrap();
4666        let mut ctx = Context::new(BrainWave::Gamma);
4667        // "delete memory <uuid>" routes to memory.delete via NLU — must be
4668        // structurally blocked even with confirm present in extracted payload.
4669        let result = wm
4670            .call(
4671                &mut ctx,
4672                json!({"thought": "delete memory 00000000-0000-0000-0000-000000000001"}),
4673            )
4674            .await
4675            .unwrap();
4676
4677        assert_eq!(result["status"], "error");
4678        assert!(
4679            result["message"]
4680                .as_str()
4681                .unwrap()
4682                .contains("cannot be reached via natural language"),
4683            "expected NLU hard-block message, got: {result}"
4684        );
4685    }
4686
4687    #[tokio::test]
4688    async fn wm_thought_cannot_reach_destructive_tool_even_with_confirm() {
4689        let store = test_store();
4690        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4691
4692        let wm = registry.get("wm").unwrap();
4693        let mut ctx = Context::new(BrainWave::Gamma);
4694        // An LLM that guesses the confirm requirement (and supplies the id)
4695        // must still be blocked — NLU routing is structurally barred from
4696        // destructive tools.
4697        let result = wm
4698            .call(
4699                &mut ctx,
4700                json!({"thought": "delete memory 00000000-0000-0000-0000-000000000001", "args": {"confirm": true, "id": "00000000-0000-0000-0000-000000000001"}}),
4701            )
4702            .await
4703            .unwrap();
4704
4705        assert_eq!(result["status"], "error");
4706        assert!(
4707            result["message"]
4708                .as_str()
4709                .unwrap()
4710                .contains("cannot be reached via natural language")
4711        );
4712    }
4713
4714    /// P0 acceptance test: every destructive tool in the registry is blocked
4715    /// when reached via natural-language routing (thought=). This sweeps all
4716    /// registered tools, filters to those with `destructive: true`, and
4717    /// verifies each one returns the hard-block error — not just memory.delete.
4718    #[tokio::test]
4719    async fn nlu_cannot_reach_any_destructive_tool() {
4720        let store = test_store();
4721        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4722        let wm = registry.get("wm").unwrap();
4723
4724        // Collect all destructive tool names from the registry (excluding
4725        // `wm` itself, which is pure — it routes, it doesn't mutate).
4726        let destructive_tools: Vec<String> = registry
4727            .all_ref()
4728            .iter()
4729            .filter(|t| t.effects().destructive)
4730            .map(|t| t.name().to_string())
4731            .collect();
4732
4733        assert!(
4734            !destructive_tools.is_empty(),
4735            "registry must contain at least one destructive tool for this test to be meaningful"
4736        );
4737
4738        let mut ctx = Context::new(BrainWave::Gamma);
4739        for tool_name in &destructive_tools {
4740            // Attempt 1: bare tool name as thought with confirm=true.
4741            // If NLU routes to the destructive tool, the structural gate must
4742            // block it. If NLU routes elsewhere, that's also fine.
4743            let result = wm
4744                .call(
4745                    &mut ctx,
4746                    json!({
4747                        "thought": tool_name,
4748                        "args": {"confirm": true}
4749                    }),
4750                )
4751                .await
4752                .unwrap();
4753
4754            // A destructive tool must never EXECUTE via NLU. Fuzzy routing
4755            // may land on a different, non-destructive tool that succeeds —
4756            // that is fine. What must not happen is success from a tool
4757            // whose own effects are destructive.
4758            let routed_tool = result
4759                .get("_wm_route")
4760                .and_then(|r| r.get("tool"))
4761                .and_then(|t| t.as_str())
4762                .unwrap_or("");
4763            let resolved_destructive = registry
4764                .get(routed_tool)
4765                .is_some_and(|t| t.effects().destructive);
4766            assert!(
4767                result["status"] != "success" || !resolved_destructive,
4768                "destructive tool '{tool_name}' executed via NLU (resolved as '{routed_tool}') — structural gate failed"
4769            );
4770
4771            // If NLU did route to the destructive tool, the gate message must
4772            // be present (proving the structural block, not just a miss).
4773            if routed_tool == tool_name {
4774                assert!(
4775                    result
4776                        .get("message")
4777                        .and_then(|m| m.as_str())
4778                        .is_some_and(|m| m.contains("cannot be reached via natural language")),
4779                    "destructive tool '{tool_name}' was routed to but gate message missing: {result}"
4780                );
4781            }
4782
4783            // Attempt 2: natural-language phrasing that might route to the
4784            // destructive tool (e.g., "rollback the transaction"). This
4785            // catches the case where the tool name itself doesn't match NLU
4786            // profiles but a natural phrase does.
4787            let nl_phrase = match tool_name.as_str() {
4788                "memory.delete" => "delete memory 00000000-0000-0000-0000-000000000001",
4789                "transaction.rollback" => "rollback the transaction",
4790                "galaxy.purge" => "purge galaxy codex",
4791                "galaxy.transfer" => "transfer galaxy codex to archive",
4792                "galaxy.restore" => "restore galaxy codex from snapshot",
4793                "memory.consolidate" => "consolidate memories in codex",
4794                "memory.deduplicate" => "deduplicate memories in codex",
4795                "karma.purge" => "purge karma ledger",
4796                "system.flush" => "flush low importance memories",
4797                "galaxy.cold_rotate" => "rotate telemetry noise to cold storage",
4798                _ => tool_name.as_str(),
4799            };
4800            let result2 = wm
4801                .call(&mut ctx, json!({"thought": nl_phrase}))
4802                .await
4803                .unwrap();
4804
4805            let routed_tool2 = result2
4806                .get("_wm_route")
4807                .and_then(|r| r.get("tool"))
4808                .and_then(|t| t.as_str())
4809                .unwrap_or("");
4810            let resolved_destructive2 = registry
4811                .get(routed_tool2)
4812                .is_some_and(|t| t.effects().destructive);
4813            assert!(
4814                result2["status"] != "success" || !resolved_destructive2,
4815                "destructive tool '{tool_name}' executed via NLU phrase '{nl_phrase}' (resolved as '{routed_tool2}') — structural gate failed"
4816            );
4817            if routed_tool2 == tool_name {
4818                assert!(
4819                    result2
4820                        .get("message")
4821                        .and_then(|m| m.as_str())
4822                        .is_some_and(|m| m.contains("cannot be reached via natural language")),
4823                    "destructive tool '{tool_name}' was routed to via '{nl_phrase}' but gate message missing: {result2}"
4824                );
4825            }
4826        }
4827    }
4828
4829    #[tokio::test]
4830    async fn nlu_abstention_returns_error_for_unmatched_query() {
4831        let store = test_store();
4832        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4833        let wm = registry.get("wm").unwrap();
4834        let mut ctx = Context::new(BrainWave::Gamma);
4835
4836        // A nonsense query that won't match any tool profile — should
4837        // abstain and return an error with the abstention flag set.
4838        let result = wm
4839            .call(&mut ctx, json!({"thought": "xyzzy quux blargh frobnicate"}))
4840            .await
4841            .unwrap();
4842
4843        assert_eq!(result["status"], "error");
4844        assert!(
4845            result
4846                .get("_wm_route")
4847                .and_then(|r| r.get("abstained"))
4848                .and_then(serde_json::Value::as_bool)
4849                .unwrap_or(false),
4850            "expected abstained=true, got: {result}"
4851        );
4852        assert!(
4853            result["message"]
4854                .as_str()
4855                .unwrap()
4856                .contains("Could not confidently match"),
4857            "expected abstention message, got: {result}"
4858        );
4859    }
4860
4861    #[tokio::test]
4862    async fn nlu_abstention_does_not_fire_for_explicit_route() {
4863        let store = test_store();
4864        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4865        let wm = registry.get("wm").unwrap();
4866        let mut ctx = Context::new(BrainWave::Gamma);
4867
4868        // Explicit route to gnosis should work even though gnosis is the
4869        // fallback tool — abstention only applies to NLU routing.
4870        let result = wm.call(&mut ctx, json!({"route": "gnosis"})).await.unwrap();
4871
4872        assert_eq!(result["status"], "success");
4873        assert!(
4874            !result
4875                .get("_wm_route")
4876                .and_then(|r| r.get("abstained"))
4877                .and_then(serde_json::Value::as_bool)
4878                .unwrap_or(false),
4879            "explicit route should not abstain, got: {result}"
4880        );
4881    }
4882
4883    /// Deterministic fake embedder — exercises the embedding router path
4884    /// without the stub auto-detect kicking in (backend name != "stub").
4885    struct FakeVecEmbedder;
4886
4887    impl wm_memory::Embedder for FakeVecEmbedder {
4888        fn embed_batch(&self, texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
4889            Ok(texts
4890                .iter()
4891                .map(|t| {
4892                    let mut v = vec![0.0_f32; 16];
4893                    for (i, b) in t.bytes().take(16).enumerate() {
4894                        v[i] = f32::from(b) / 255.0;
4895                    }
4896                    v
4897                })
4898                .collect())
4899        }
4900        fn dimension(&self) -> usize {
4901            16
4902        }
4903        fn is_available(&self) -> bool {
4904            true
4905        }
4906        fn backend_name(&self) -> &'static str {
4907            "fake"
4908        }
4909    }
4910
4911    #[tokio::test]
4912    async fn wm_classify_async_routes_off_thread_with_embedding_router() {
4913        let store = test_store();
4914        let registry = test_registry_with(&store);
4915        let shadow = std::sync::Arc::new(std::sync::RwLock::new(
4916            embedding_router::ShadowModeStats::default(),
4917        ));
4918        let router = embedding_router::EmbeddingRouter::with_descriptions(
4919            Box::new(FakeVecEmbedder),
4920            embedding_router::tool_descriptions(),
4921        )
4922        .expect("fake-embedder router should build");
4923        let mut meta = WmMetaTool::with_router_shadow_stats_and_pipeline(
4924            std::sync::Arc::new(registry),
4925            wm_memory::create_embedder(),
4926            shadow,
4927            None,
4928        );
4929        meta.embedding_router = Some(std::sync::Arc::new(router));
4930
4931        // Runs through spawn_blocking; on the current-thread test runtime this
4932        // proves the classification path is runtime-agnostic and completes.
4933        let (tool, conf, emb) = meta.classify_async("remember the meeting notes").await;
4934        assert!(!tool.is_empty());
4935        assert!(conf >= 0.0);
4936        assert!(
4937            emb.is_some(),
4938            "query embedding should be returned for OATS reuse"
4939        );
4940    }
4941
4942    #[tokio::test]
4943    async fn tools_list_shows_all() {
4944        let store = test_store();
4945        let registry = test_registry_with(&store);
4946        let registry = register_meta_tools(
4947            &registry,
4948            &store,
4949            std::sync::Arc::new(std::sync::RwLock::new(
4950                embedding_router::ShadowModeStats::default(),
4951            )),
4952        );
4953
4954        let list = registry.get("tools.list").unwrap();
4955        let mut ctx = Context::new(BrainWave::Gamma);
4956        let result = list.call(&mut ctx, json!({})).await.unwrap();
4957
4958        assert_eq!(result["status"], "success");
4959        assert!(result["total"].as_u64().unwrap() >= 7);
4960    }
4961
4962    #[tokio::test]
4963    async fn tools_list_exposes_curated_argument_schemas() {
4964        let store = test_store();
4965        let registry = test_registry_with(&store);
4966        let registry = register_meta_tools(
4967            &registry,
4968            &store,
4969            std::sync::Arc::new(std::sync::RwLock::new(
4970                embedding_router::ShadowModeStats::default(),
4971            )),
4972        );
4973
4974        let list = registry.get("tools.list").unwrap();
4975        let mut ctx = Context::new(BrainWave::Gamma);
4976        let result = list.call(&mut ctx, json!({})).await.unwrap();
4977
4978        let tools = result["tools"].as_array().unwrap();
4979        let create = tools
4980            .iter()
4981            .find(|t| t["name"] == "memory.create")
4982            .expect("tools.list must include memory.create");
4983        let schema = &create["input_schema"];
4984        assert_eq!(schema["type"], "object");
4985        assert!(
4986            schema["properties"].get("content").is_some(),
4987            "memory.create schema must describe content, got: {schema}"
4988        );
4989        assert!(
4990            schema["required"]
4991                .as_array()
4992                .unwrap()
4993                .iter()
4994                .any(|r| r == "content"),
4995            "memory.create schema must require content"
4996        );
4997
4998        let rollback = tools
4999            .iter()
5000            .find(|t| t["name"] == "transaction.rollback")
5001            .expect("tools.list must include transaction.rollback");
5002        assert!(
5003            rollback["input_schema"]["required"]
5004                .as_array()
5005                .unwrap()
5006                .iter()
5007                .any(|r| r == "confirm"),
5008            "transaction.rollback schema must require confirm"
5009        );
5010
5011        // MCP annotations derived from EffectRow.
5012        let annotations = &create["annotations"];
5013        assert_eq!(annotations["readOnlyHint"], false, "memory.create writes");
5014        assert_eq!(annotations["destructiveHint"], false);
5015        assert_eq!(
5016            rollback["annotations"]["destructiveHint"], true,
5017            "transaction.rollback is destructive"
5018        );
5019        let list_tool = tools
5020            .iter()
5021            .find(|t| t["name"] == "memory.list")
5022            .expect("tools.list must include memory.list");
5023        assert_eq!(
5024            list_tool["annotations"]["readOnlyHint"], true,
5025            "memory.list is read-only"
5026        );
5027    }
5028
5029    #[tokio::test]
5030    async fn tools_list_filters_by_brain_wave() {
5031        let store = test_store();
5032        let registry = test_registry_with(&store);
5033        let registry = register_meta_tools(
5034            &registry,
5035            &store,
5036            std::sync::Arc::new(std::sync::RwLock::new(
5037                embedding_router::ShadowModeStats::default(),
5038            )),
5039        );
5040
5041        let list = registry.get("tools.list").unwrap();
5042
5043        // Gamma: all tools available
5044        let mut ctx_gamma = Context::new(BrainWave::Gamma);
5045        let result_gamma = list.call(&mut ctx_gamma, json!({})).await.unwrap();
5046        let gamma_count = result_gamma["total"].as_u64().unwrap();
5047        assert!(gamma_count >= 7);
5048
5049        // Alpha: only read-only tools (no writes, no expensive)
5050        let mut ctx_alpha = Context::new(BrainWave::Alpha);
5051        let result_alpha = list.call(&mut ctx_alpha, json!({})).await.unwrap();
5052        let alpha_count = result_alpha["total"].as_u64().unwrap();
5053        assert!(alpha_count < gamma_count);
5054        assert!(alpha_count > 0);
5055
5056        // Delta: no tools available
5057        let mut ctx_delta = Context::new(BrainWave::Delta);
5058        let result_delta = list.call(&mut ctx_delta, json!({})).await.unwrap();
5059        assert_eq!(result_delta["total"], 0);
5060    }
5061
5062    #[tokio::test]
5063    async fn gnosis_includes_brain_wave_and_tool_count() {
5064        let store = test_store();
5065        let registry = test_registry_with(&store);
5066        let registry = register_meta_tools(
5067            &registry,
5068            &store,
5069            std::sync::Arc::new(std::sync::RwLock::new(
5070                embedding_router::ShadowModeStats::default(),
5071            )),
5072        );
5073
5074        let gnosis = registry.get("gnosis").unwrap();
5075        let mut ctx = Context::new(BrainWave::Gamma);
5076        let result = gnosis.call(&mut ctx, json!({})).await.unwrap();
5077
5078        assert_eq!(result["status"], "success");
5079        assert_eq!(result["brain_wave"], "Gamma");
5080        assert!(result["available_tools"].as_u64().unwrap() >= 9);
5081    }
5082
5083    #[tokio::test]
5084    async fn gnosis_available_tools_is_total_registered() {
5085        let store = test_store();
5086        let registry = test_registry_with(&store);
5087        let registry = register_meta_tools(
5088            &registry,
5089            &store,
5090            std::sync::Arc::new(std::sync::RwLock::new(
5091                embedding_router::ShadowModeStats::default(),
5092            )),
5093        );
5094
5095        let gnosis = registry.get("gnosis").unwrap();
5096
5097        // available_tools is now a static count of registered tools,
5098        // not brain-wave-dependent. It should be the same in all states.
5099        let mut ctx_gamma = Context::new(BrainWave::Gamma);
5100        let result_gamma = gnosis.call(&mut ctx_gamma, json!({})).await.unwrap();
5101        let gamma_tools = result_gamma["available_tools"].as_u64().unwrap();
5102
5103        let mut ctx_delta = Context::new(BrainWave::Delta);
5104        let result_delta = gnosis.call(&mut ctx_delta, json!({})).await.unwrap();
5105        let delta_tools = result_delta["available_tools"].as_u64().unwrap();
5106
5107        assert_eq!(gamma_tools, delta_tools);
5108        assert!(
5109            gamma_tools >= 9,
5110            "expected at least 9 registered tools, got {gamma_tools}"
5111        );
5112    }
5113
5114    #[tokio::test]
5115    async fn expansion_brings_tool_count_to_50() {
5116        let store = test_store();
5117        let registry = test_registry_with(&store);
5118        let registry = register_meta_tools(
5119            &registry,
5120            &store,
5121            std::sync::Arc::new(std::sync::RwLock::new(
5122                embedding_router::ShadowModeStats::default(),
5123            )),
5124        );
5125
5126        let list = registry.get("tools.list").unwrap();
5127        let mut ctx = Context::new(BrainWave::Gamma);
5128        let result = list.call(&mut ctx, json!({})).await.unwrap();
5129
5130        let total = result["total"].as_u64().unwrap();
5131        assert!(
5132            total >= 50,
5133            "Expected 50+ tools after expansion, got {total}"
5134        );
5135    }
5136
5137    // ── NLU Router Expansion Tests ─────────────────────────────────────
5138
5139    #[tokio::test]
5140    async fn nlu_routes_consolidate() {
5141        let (tool, conf) = WmMetaTool::classify("consolidate memories in codex");
5142        assert_eq!(tool, "memory.consolidate");
5143        assert!(conf > 0.0);
5144    }
5145
5146    #[tokio::test]
5147    async fn nlu_routes_decay() {
5148        let (tool, conf) = WmMetaTool::classify("decay old memories");
5149        assert_eq!(tool, "memory.decay");
5150        assert!(conf > 0.0);
5151    }
5152
5153    #[tokio::test]
5154    async fn nlu_routes_batch_read() {
5155        let (tool, conf) = WmMetaTool::classify("batch read these memories");
5156        assert_eq!(tool, "memory.batch_read");
5157        assert!(conf > 0.0);
5158    }
5159
5160    #[tokio::test]
5161    async fn nlu_routes_update() {
5162        let (tool, conf) = WmMetaTool::classify("update memory tags");
5163        assert_eq!(tool, "memory.update");
5164        assert!(conf > 0.0);
5165    }
5166
5167    #[tokio::test]
5168    async fn nlu_routes_tag() {
5169        let (tool, conf) = WmMetaTool::classify("add tag to memory");
5170        assert_eq!(tool, "memory.tag");
5171        assert!(conf > 0.0);
5172    }
5173
5174    #[tokio::test]
5175    async fn nlu_routes_memory_stats() {
5176        let (tool, conf) = WmMetaTool::classify("memory stats for codex");
5177        assert_eq!(tool, "memory.stats");
5178        assert!(conf > 0.0);
5179    }
5180
5181    #[tokio::test]
5182    async fn nlu_routes_hybrid_recall() {
5183        let (tool, conf) = WmMetaTool::classify("hybrid recall for rust");
5184        assert_eq!(tool, "memory.hybrid_recall");
5185        assert!(conf > 0.0);
5186    }
5187
5188    #[tokio::test]
5189    async fn nlu_routes_count() {
5190        let (tool, conf) = WmMetaTool::classify("count memories in codex");
5191        assert_eq!(tool, "memory.count");
5192        assert!(conf > 0.0);
5193    }
5194
5195    #[tokio::test]
5196    async fn nlu_routes_tags() {
5197        let (tool, conf) = WmMetaTool::classify("list tags in codex");
5198        assert_eq!(tool, "memory.tags");
5199        assert!(conf > 0.0);
5200    }
5201
5202    #[tokio::test]
5203    async fn nlu_routes_associate_mine() {
5204        let (tool, conf) = WmMetaTool::classify("mine associations in codex");
5205        assert_eq!(tool, "memory.associate_mine");
5206        assert!(conf > 0.0);
5207    }
5208
5209    #[tokio::test]
5210    async fn nlu_routes_session_start() {
5211        let (tool, conf) = WmMetaTool::classify("start session research");
5212        assert_eq!(tool, "session.start");
5213        assert!(conf > 0.0);
5214    }
5215
5216    #[tokio::test]
5217    async fn nlu_routes_session_end() {
5218        let (tool, conf) = WmMetaTool::classify("end session 12345");
5219        assert_eq!(tool, "session.end");
5220        assert!(conf > 0.0);
5221    }
5222
5223    #[tokio::test]
5224    async fn nlu_routes_session_list() {
5225        let (tool, conf) = WmMetaTool::classify("list sessions");
5226        assert_eq!(tool, "session.list");
5227        assert!(conf > 0.0);
5228    }
5229
5230    #[tokio::test]
5231    async fn nlu_routes_citta_status() {
5232        let (tool, conf) = WmMetaTool::classify("citta status");
5233        assert_eq!(tool, "citta.status");
5234        assert!(conf > 0.0);
5235    }
5236
5237    #[tokio::test]
5238    async fn nlu_routes_citta_reflect() {
5239        let (tool, conf) = WmMetaTool::classify("reflect on recent events");
5240        assert_eq!(tool, "citta.reflect");
5241        assert!(conf > 0.0);
5242    }
5243
5244    #[tokio::test]
5245    async fn nlu_routes_coherence() {
5246        let (tool, conf) = WmMetaTool::classify("check coherence");
5247        assert_eq!(tool, "citta.coherence");
5248        assert!(conf > 0.0);
5249    }
5250
5251    #[tokio::test]
5252    async fn nlu_routes_dream_status() {
5253        let (tool, conf) = WmMetaTool::classify("dream cycle status");
5254        assert_eq!(tool, "dream.status");
5255        assert!(conf > 0.0);
5256    }
5257
5258    #[tokio::test]
5259    async fn nlu_routes_dream_trigger() {
5260        let (tool, conf) = WmMetaTool::classify("trigger dream cycle");
5261        assert_eq!(tool, "dream.trigger");
5262        assert!(conf > 0.0);
5263    }
5264
5265    #[tokio::test]
5266    async fn nlu_routes_effectiveness() {
5267        let (tool, conf) = WmMetaTool::classify("tool effectiveness report");
5268        assert_eq!(tool, "tools.effectiveness_report");
5269        assert!(conf > 0.0);
5270    }
5271
5272    #[tokio::test]
5273    async fn nlu_routes_retire() {
5274        let (tool, conf) = WmMetaTool::classify("retire tool memory.old");
5275        assert_eq!(tool, "tools.retire");
5276        assert!(conf > 0.0);
5277    }
5278
5279    #[tokio::test]
5280    async fn nlu_routes_pattern_search() {
5281        let (tool, conf) = WmMetaTool::classify("pattern search for rust");
5282        assert_eq!(tool, "pattern.search");
5283        assert!(conf > 0.0);
5284    }
5285
5286    #[tokio::test]
5287    async fn nlu_routes_salience() {
5288        let (tool, conf) = WmMetaTool::classify("salience spotlight");
5289        assert_eq!(tool, "salience.spotlight");
5290        assert!(conf > 0.0);
5291    }
5292
5293    #[tokio::test]
5294    async fn nlu_routes_serendipity() {
5295        let (tool, conf) = WmMetaTool::classify("serendipity surface");
5296        assert_eq!(tool, "serendipity.surface");
5297        assert!(conf > 0.0);
5298    }
5299
5300    #[tokio::test]
5301    async fn nlu_routes_constellation_detect() {
5302        let (tool, conf) = WmMetaTool::classify("detect clusters");
5303        assert_eq!(tool, "constellation.detect");
5304        assert!(conf > 0.0);
5305    }
5306
5307    #[tokio::test]
5308    async fn nlu_routes_constellation_list() {
5309        let (tool, conf) = WmMetaTool::classify("list constellations");
5310        assert_eq!(tool, "constellation.list");
5311        assert!(conf > 0.0);
5312    }
5313
5314    #[tokio::test]
5315    async fn nlu_routes_galaxy_stats() {
5316        let (tool, conf) = WmMetaTool::classify("galaxy stats");
5317        assert_eq!(tool, "galaxy.stats");
5318        assert!(conf > 0.0);
5319    }
5320
5321    #[tokio::test]
5322    async fn nlu_routes_galaxy_export() {
5323        let (tool, conf) = WmMetaTool::classify("export galaxy codex");
5324        assert_eq!(tool, "galaxy.export");
5325        assert!(conf > 0.0);
5326    }
5327
5328    #[tokio::test]
5329    async fn nlu_routes_galaxy_import() {
5330        let (tool, conf) = WmMetaTool::classify("import galaxy codex");
5331        assert_eq!(tool, "galaxy.import");
5332        assert!(conf > 0.0);
5333    }
5334
5335    #[tokio::test]
5336    async fn nlu_routes_karma_history() {
5337        let (tool, conf) = WmMetaTool::classify("karma history");
5338        assert_eq!(tool, "karma.history");
5339        assert!(conf > 0.0);
5340    }
5341
5342    #[tokio::test]
5343    async fn nlu_routes_karma_clear() {
5344        let (tool, conf) = WmMetaTool::classify("clear karma");
5345        assert_eq!(tool, "karma.clear");
5346        assert!(conf > 0.0);
5347    }
5348
5349    #[tokio::test]
5350    async fn nlu_routes_dharma_rules() {
5351        let (tool, conf) = WmMetaTool::classify("dharma rules");
5352        assert_eq!(tool, "dharma.rules");
5353        assert!(conf > 0.0);
5354    }
5355
5356    #[tokio::test]
5357    async fn nlu_routes_dharma_audit() {
5358        let (tool, conf) = WmMetaTool::classify("dharma audit");
5359        assert_eq!(tool, "dharma.audit");
5360        assert!(conf > 0.0);
5361    }
5362
5363    #[tokio::test]
5364    async fn nlu_routes_dharma_profiles() {
5365        let (tool, conf) = WmMetaTool::classify("dharma profiles");
5366        assert_eq!(tool, "dharma.profiles");
5367        assert!(conf > 0.0);
5368    }
5369
5370    #[tokio::test]
5371    async fn nlu_routes_agent_register() {
5372        let (tool, conf) = WmMetaTool::classify("register agent worker-1");
5373        assert_eq!(tool, "agent.register");
5374        assert!(conf > 0.0);
5375    }
5376
5377    #[tokio::test]
5378    async fn nlu_routes_agent_list() {
5379        let (tool, conf) = WmMetaTool::classify("list agents");
5380        assert_eq!(tool, "agent.list");
5381        assert!(conf > 0.0);
5382    }
5383
5384    #[tokio::test]
5385    async fn nlu_routes_agent_heartbeat() {
5386        let (tool, conf) = WmMetaTool::classify("heartbeat for agent");
5387        assert_eq!(tool, "agent.heartbeat");
5388        assert!(conf > 0.0);
5389    }
5390
5391    #[tokio::test]
5392    async fn nlu_routes_task_distribute() {
5393        let (tool, conf) = WmMetaTool::classify("distribute task analyze data");
5394        assert_eq!(tool, "task.distribute");
5395        assert!(conf > 0.0);
5396    }
5397
5398    #[tokio::test]
5399    async fn nlu_routes_task_status() {
5400        let (tool, conf) = WmMetaTool::classify("task status");
5401        assert_eq!(tool, "task.status");
5402        assert!(conf > 0.0);
5403    }
5404
5405    #[tokio::test]
5406    async fn nlu_routes_system_health() {
5407        let (tool, conf) = WmMetaTool::classify("system health check");
5408        assert_eq!(tool, "system.health");
5409        assert!(conf > 0.0);
5410    }
5411
5412    #[tokio::test]
5413    async fn nlu_routes_system_config() {
5414        let (tool, conf) = WmMetaTool::classify("system config");
5415        assert_eq!(tool, "system.config");
5416        assert!(conf > 0.0);
5417    }
5418
5419    #[tokio::test]
5420    async fn nlu_routes_system_flush() {
5421        let (tool, conf) = WmMetaTool::classify("flush old memories");
5422        assert_eq!(tool, "system.flush");
5423        assert!(conf > 0.0);
5424    }
5425
5426    #[tokio::test]
5427    async fn nlu_routes_memory_nearby() {
5428        let (tool, conf) = WmMetaTool::classify("nearby memories in codex");
5429        assert_eq!(tool, "memory.nearby");
5430        assert!(conf > 0.0);
5431    }
5432
5433    #[tokio::test]
5434    async fn nlu_routes_empty_to_gnosis() {
5435        let (tool, conf) = WmMetaTool::classify("");
5436        assert_eq!(tool, "gnosis");
5437        assert_eq!(conf, 0.0);
5438    }
5439
5440    #[tokio::test]
5441    async fn nlu_routes_unknown_to_gnosis() {
5442        let (tool, conf) = WmMetaTool::classify("xyzzy frobnicate");
5443        assert_eq!(tool, "gnosis");
5444        assert_eq!(conf, 0.0);
5445    }
5446
5447    #[tokio::test]
5448    async fn nlu_extract_payload_memory_search() {
5449        let (param, value) =
5450            WmMetaTool::extract_payload("search for rust patterns", "memory.search").unwrap();
5451        assert_eq!(param, "query");
5452        assert_eq!(value, "rust patterns");
5453    }
5454
5455    #[tokio::test]
5456    async fn nlu_extract_payload_session_start() {
5457        // Regression: the payload key was "name", which session.start never
5458        // reads — natural-language session starts silently created
5459        // "Untitled Session" entries.
5460        let (param, value) =
5461            WmMetaTool::extract_payload("start session research", "session.start").unwrap();
5462        assert_eq!(param, "title");
5463        assert_eq!(value, "research");
5464    }
5465
5466    #[tokio::test]
5467    async fn nlu_extract_payload_agent_register() {
5468        let (param, value) =
5469            WmMetaTool::extract_payload("register agent worker-1", "agent.register").unwrap();
5470        assert_eq!(param, "name");
5471        assert_eq!(value, "worker-1");
5472    }
5473
5474    #[tokio::test]
5475    async fn nlu_extract_payload_task_distribute() {
5476        let (param, value) =
5477            WmMetaTool::extract_payload("distribute task analyze data", "task.distribute").unwrap();
5478        assert_eq!(param, "task");
5479        assert_eq!(value, "analyze data");
5480    }
5481
5482    #[tokio::test]
5483    async fn nlu_count_unique_patterns() {
5484        // Verify we have 30+ unique routing targets
5485        let inputs = [
5486            "remember",
5487            "recall",
5488            "list memories",
5489            "delete memory",
5490            "search",
5491            "query",
5492            "associate",
5493            "associations",
5494            "consolidate",
5495            "decay",
5496            "batch read",
5497            "update memory",
5498            "tag memory",
5499            "memory stats",
5500            "hybrid recall",
5501            "count memories",
5502            "list tags",
5503            "mine associations",
5504            "start session",
5505            "checkpoint",
5506            "recall session",
5507            "end session",
5508            "list sessions",
5509            "citta status",
5510            "reflect",
5511            "coherence",
5512            "dream status",
5513            "trigger dream",
5514            "effectiveness",
5515            "retire tool",
5516            "pattern search",
5517            "salience",
5518            "serendipity",
5519            "detect clusters",
5520            "list constellations",
5521            "galaxy stats",
5522            "export galaxy",
5523            "import galaxy",
5524            "karma",
5525            "karma history",
5526            "clear karma",
5527            "dharma rules",
5528            "dharma audit",
5529            "dharma profiles",
5530            "dharma",
5531            "register agent",
5532            "list agents",
5533            "heartbeat",
5534            "distribute task",
5535            "task status",
5536            "system health",
5537            "system config",
5538            "flush",
5539            "tools",
5540            "nearby memories",
5541        ];
5542        let mut tools: std::collections::HashSet<&str> = std::collections::HashSet::new();
5543        for input in &inputs {
5544            let (tool, _) = WmMetaTool::classify(input);
5545            tools.insert(tool);
5546        }
5547        // Should have 30+ unique tool targets
5548        assert!(
5549            tools.len() >= 30,
5550            "Expected 30+ unique NLU targets, got {}",
5551            tools.len()
5552        );
5553    }
5554
5555    #[tokio::test]
5556    async fn nlu_routes_shadow_report() {
5557        let (tool, conf) = WmMetaTool::classify("shadow mode disagreement report");
5558        assert_eq!(tool, "nlu.shadow_report");
5559        assert!(conf > 0.0);
5560    }
5561
5562    #[tokio::test]
5563    async fn nlu_routes_oats_report() {
5564        let (tool, conf) = WmMetaTool::classify("oats disagreement nlu router");
5565        assert_eq!(tool, "nlu.shadow_report");
5566        assert!(conf > 0.0);
5567    }
5568}