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