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