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) -> ToolRegistry {
3556    let mut reg = registry
3557        .register(Arc::new(MemoryCreateTool::new(
3558            store.clone(),
3559            search.clone(),
3560            recall.clone(),
3561        )))
3562        .register(Arc::new(MemoryBatchCreateTool::new(
3563            store.clone(),
3564            search.clone(),
3565            recall.clone(),
3566        )))
3567        .register(Arc::new(MemoryReadTool::new(store.clone())))
3568        .register(Arc::new(MemoryListTool::new(store.clone())))
3569        .register(Arc::new(MemoryDeleteTool::new(
3570            store.clone(),
3571            search.clone(),
3572        )))
3573        .register(Arc::new(MemoryBatchDeleteTool::new(
3574            store.clone(),
3575            search.clone(),
3576        )))
3577        .register(Arc::new(MemoryQueryTool::new(store.clone())))
3578        .register(Arc::new(MemoryAssociateTool::new(store.clone())))
3579        .register(Arc::new(MemoryAssociationsTool::new(store.clone())))
3580        .register(Arc::new(MemoryVectorSearchTool::new(
3581            store.clone(),
3582            vector_store,
3583        )))
3584        .register(Arc::new(GnosisTool::new(store.clone())));
3585
3586    if let Some(conv) = conversational {
3587        reg = reg.register(Arc::new(MemoryChatTool::new(conv)));
3588    }
3589
3590    if let Some(s) = search {
3591        // Public retrieval verb shares the hybrid implementation.
3592        // memory.hybrid_recall is registered as a compatibility alias
3593        // inside register_expansion.
3594        reg = reg.register(Arc::new(
3595            expansion::MemoryHybridRecallTool::as_search(
3596                store.clone(),
3597                Some(s.clone()),
3598                recall.clone(),
3599            )
3600            .with_associations(Some(associations.clone())),
3601        ));
3602        // Pass search to expansion tools
3603        reg = expansion::register_expansion(
3604            &reg,
3605            store,
3606            Some(s),
3607            recall,
3608            associations,
3609            spiral_tracker,
3610            karma.clone(),
3611            substrate.clone(),
3612            homeostatic_loop,
3613            anomaly_detector,
3614            sensorimotor_bus,
3615            reflex_loop,
3616            gan_ying_bus,
3617            transaction_state,
3618            resource_rules.as_ref(),
3619            escalation_queue,
3620            dharma.as_ref(),
3621            firewall,
3622            code_graph,
3623            registry_persistence,
3624        );
3625    } else {
3626        reg = expansion::register_expansion(
3627            &reg,
3628            store,
3629            None,
3630            recall,
3631            associations,
3632            spiral_tracker,
3633            karma.clone(),
3634            substrate.clone(),
3635            homeostatic_loop,
3636            anomaly_detector,
3637            sensorimotor_bus,
3638            reflex_loop,
3639            gan_ying_bus,
3640            transaction_state,
3641            resource_rules.as_ref(),
3642            escalation_queue,
3643            dharma.as_ref(),
3644            firewall,
3645            code_graph,
3646            registry_persistence,
3647        );
3648    }
3649    if let Some(k) = karma {
3650        reg = reg.register(Arc::new(KarmaReportTool::new(k)));
3651    }
3652    if let Some(d) = dharma {
3653        reg = reg.register(Arc::new(DharmaStatusTool::new(d.clone())));
3654    }
3655    if let Some(s) = substrate {
3656        reg = reg
3657            .register(Arc::new(HarmonyVectorTool::new(s.clone())))
3658            .register(Arc::new(HarmonyHistoryTool::new(s.clone())));
3659        if let Some(d) = dharma {
3660            if let Some(r) = resource_rules {
3661                reg = reg
3662                    .register(Arc::new(GnosisStatusTool::new(
3663                        d.clone(),
3664                        r.clone(),
3665                        s.clone(),
3666                    )))
3667                    .register(Arc::new(GnosisHistoryTool::new(s)))
3668                    .register(Arc::new(GnosisExplainTool::new(d.clone(), r.clone())));
3669            }
3670        }
3671    }
3672
3673    reg
3674}
3675
3676/// Register tools.list and wm meta-tool after the base tools are registered.
3677///
3678/// This requires a two-phase approach because tools.list needs the registry.
3679/// Also creates GnosisTool with registry access for brain-wave-aware tool counting.
3680/// The `shadow_stats` Arc is shared between the `WmMetaTool` and `NluShadowReportTool`.
3681pub fn register_meta_tools(
3682    registry: &ToolRegistry,
3683    store: &Arc<MemoryStore>,
3684    shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
3685) -> ToolRegistry {
3686    register_meta_tools_with_router(registry, store, shadow_stats, None).0
3687}
3688
3689/// Register the meta-tools and return the embedding router alongside.
3690///
3691/// The router is returned so the caller can persist/restore OATS outcome
3692/// stats (`save_oats` / `load_oats`) across restarts — the outcome-aware
3693/// refinement that makes NLU routing learn from dispatch outcomes.
3694///
3695/// When `pipeline` is `Some`, the `wm` meta-tool dispatches inner tools through
3696/// the full governance pipeline (destructive confirmation, dharma gate, rate
3697/// limit, circuit breaker, karma record, per-tool stats).
3698#[must_use]
3699pub fn register_meta_tools_with_router(
3700    registry: &ToolRegistry,
3701    store: &Arc<MemoryStore>,
3702    shadow_stats: Arc<std::sync::RwLock<embedding_router::ShadowModeStats>>,
3703    pipeline: Option<Arc<DispatchPipeline>>,
3704) -> (ToolRegistry, Option<Arc<embedding_router::EmbeddingRouter>>) {
3705    let base_snapshot: Vec<Arc<dyn Tool>> = registry.all();
3706    // Count includes old gnosis (which will be replaced with tool-count-aware version)
3707    let tool_count = base_snapshot.len();
3708
3709    let non_gnosis: Vec<Arc<dyn Tool>> = base_snapshot
3710        .iter()
3711        .filter(|t| t.name() != "gnosis")
3712        .cloned()
3713        .collect();
3714
3715    // Build tools.list with snapshot of non-gnosis tools
3716    let mut list_builder = ToolRegistryBuilder::new();
3717    for tool in &non_gnosis {
3718        list_builder.register(tool.clone());
3719    }
3720    let list_registry = Arc::new(list_builder.build());
3721    let tools_list = Arc::new(ToolsListTool::new(Arc::clone(&list_registry)));
3722
3723    // tools.usage_report shares the same registry snapshot — the tool Arcs
3724    // (and their ToolStats atomics) are shared across registries, so the
3725    // report reads the same counters the dispatch pipeline updates.
3726    let usage_report = Arc::new(expansion::ToolsUsageReportTool::new(list_registry));
3727
3728    // Build wm with all base tools (non-gnosis) + tools.list + new gnosis
3729    let gnosis = Arc::new(GnosisTool::with_tool_count(Arc::clone(store), tool_count));
3730    let mut wm_builder = ToolRegistryBuilder::new();
3731    for tool in &non_gnosis {
3732        wm_builder.register(tool.clone());
3733    }
3734    wm_builder.register(tools_list.clone());
3735    wm_builder.register(usage_report.clone());
3736    wm_builder.register(gnosis.clone());
3737
3738    // Create NLU shadow report tool sharing the same shadow stats.
3739    // Registered inside the wm meta-tool's routing registry so
3740    // `wm(route="nlu.shadow_report")` is reachable — the MCP boundary only
3741    // exposes the `wm` meta-tool, so top-level-only registration was unreachable.
3742    let shadow_report = Arc::new(expansion::NluShadowReportTool::new(Arc::clone(
3743        &shadow_stats,
3744    )));
3745    wm_builder.register(shadow_report.clone());
3746    let wm = Arc::new(WmMetaTool::with_router_shadow_stats_and_pipeline(
3747        Arc::new(wm_builder.build()),
3748        wm_memory::create_embedder(),
3749        shadow_stats,
3750        pipeline,
3751    ));
3752    let router = wm.embedding_router().cloned();
3753
3754    // Build the final registry: non-gnosis + tools.list + usage report + wm + gnosis + shadow report
3755    let mut final_builder = ToolRegistryBuilder::new();
3756    for tool in non_gnosis {
3757        final_builder.register(tool);
3758    }
3759    final_builder.register(tools_list);
3760    final_builder.register(usage_report);
3761    final_builder.register(wm);
3762    final_builder.register(gnosis);
3763    final_builder.register(shadow_report);
3764    (final_builder.build(), router)
3765}
3766
3767#[cfg(test)]
3768mod tests {
3769    use super::*;
3770    use wm_core::BrainWave;
3771
3772    fn test_store() -> Arc<MemoryStore> {
3773        let tmp = tempfile::tempdir().unwrap();
3774        Arc::new(MemoryStore::open_default(tmp.path()).unwrap())
3775    }
3776
3777    #[tokio::test]
3778    async fn memory_create_warns_on_credential_shaped_content() {
3779        let store = test_store();
3780        let tool = MemoryCreateTool::new(store, None, None);
3781        let mut ctx = Context::default();
3782
3783        let clean = tool
3784            .call(
3785                &mut ctx,
3786                json!({"content": "the password policy requires rotation"}),
3787            )
3788            .await
3789            .unwrap();
3790        assert!(clean.get("warnings").is_none(), "clean content: {clean}");
3791
3792        let flagged = tool
3793            .call(
3794                &mut ctx,
3795                json!({"content": "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----"}),
3796            )
3797            .await
3798            .unwrap();
3799        assert_eq!(
3800            flagged["status"], "success",
3801            "warning, not refusal: {flagged}"
3802        );
3803        let warnings = flagged["warnings"].as_array().unwrap();
3804        assert!(
3805            warnings[0].as_str().unwrap().contains("private_key_pem"),
3806            "got: {warnings:?}"
3807        );
3808        assert!(warnings[0].as_str().unwrap().contains("keyring"));
3809    }
3810
3811    #[tokio::test]
3812    async fn memory_batch_create_aggregates_credential_warnings() {
3813        let store = test_store();
3814        let tool = MemoryBatchCreateTool::new(store, None, None);
3815        let mut ctx = Context::default();
3816        let r = tool
3817            .call(
3818                &mut ctx,
3819                json!({"items": [
3820                    {"content": "ordinary note"},
3821                    {"content": "AKIAIOSFODNN7EXAMPLE"},
3822                ]}),
3823            )
3824            .await
3825            .unwrap();
3826        assert_eq!(r["count"], 2);
3827        let warnings = r["warnings"].as_array().unwrap();
3828        assert!(warnings[0].as_str().unwrap().contains("aws_access_key_id"));
3829    }
3830
3831    fn test_registry_with(store: &Arc<MemoryStore>) -> ToolRegistry {
3832        let registry = ToolRegistry::new();
3833        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
3834        let spiral_tracker =
3835            Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
3836        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
3837        register_all(
3838            &registry,
3839            store,
3840            None,
3841            None,
3842            &None,
3843            None,
3844            &None,
3845            associations,
3846            spiral_tracker,
3847            vector_store,
3848            None,
3849            None,
3850            None,
3851            None,
3852            None,
3853            None,
3854            None,
3855            std::sync::Arc::new(std::sync::Mutex::new(None)),
3856            None,
3857            None,
3858            None,
3859            expansion::RegistryPersistenceMode::Normal,
3860        )
3861    }
3862
3863    #[tokio::test]
3864    async fn memory_create_and_read() {
3865        let store = test_store();
3866        let tool = MemoryCreateTool::new(store.clone(), None, None);
3867        let mut ctx = Context::new(BrainWave::Gamma);
3868
3869        let args = json!({"content": "test memory content", "galaxy": "codex"});
3870        let result = tool.call(&mut ctx, args).await.unwrap();
3871        assert_eq!(result["status"], "success");
3872        let id = result["id"].as_str().unwrap();
3873
3874        let read_tool = MemoryReadTool::new(store.clone());
3875        let result = read_tool.call(&mut ctx, json!({"id": id})).await.unwrap();
3876        assert_eq!(result["status"], "success");
3877        assert_eq!(result["content"], "test memory content");
3878
3879        let episodic = store
3880            .episodic()
3881            .get(uuid::Uuid::parse_str(id).unwrap())
3882            .unwrap()
3883            .expect("explicit memory writes mirror into episodic storage");
3884        assert_eq!(episodic.content, "test memory content");
3885    }
3886
3887    /// Track F Slice A: `attested` disclosure on memory.create. Fully
3888    /// hermetic — keys flow through the `with_attestation_key` seam, never
3889    /// the process environment (this crate forbids `unsafe`, and env
3890    /// mutation is `unsafe` in edition 2024).
3891    #[tokio::test]
3892    async fn memory_create_attestation_disclosure() {
3893        const TEST_KEY: &str = "0bd1c44170ca3d916648a983dcdb8583d22f2da5b29fdd5ede4b38e805435577";
3894        let mut ctx = Context::new(BrainWave::Gamma);
3895
3896        // Path 1: no key — honest negative, create still succeeds.
3897        let store = test_store();
3898        let tool = MemoryCreateTool::with_attestation_key(store.clone(), None, None, None);
3899        let result = tool
3900            .call(
3901                &mut ctx,
3902                json!({"content": "unattested create", "galaxy": "codex"}),
3903            )
3904            .await
3905            .unwrap();
3906        assert_eq!(result["status"], "success");
3907        assert_eq!(result["attested"], false);
3908        assert_eq!(result["attested_reason"], "node key unavailable");
3909
3910        // Path 2: invalid key material — honest negative, create succeeds.
3911        let tool = MemoryCreateTool::with_attestation_key(
3912            store.clone(),
3913            None,
3914            None,
3915            Some("not-hex".to_string()),
3916        );
3917        let result = tool
3918            .call(
3919                &mut ctx,
3920                json!({"content": "bad key create", "galaxy": "codex"}),
3921            )
3922            .await
3923            .unwrap();
3924        assert_eq!(result["attested"], false);
3925        assert_eq!(result["attested_reason"], "node key invalid");
3926
3927        // Path 3: key present — signed, stored, verifiable.
3928        let tool = MemoryCreateTool::with_attestation_key(
3929            store.clone(),
3930            None,
3931            None,
3932            Some(TEST_KEY.to_string()),
3933        );
3934        let result = tool
3935            .call(
3936                &mut ctx,
3937                json!({"content": "attested create", "galaxy": "codex"}),
3938            )
3939            .await
3940            .unwrap();
3941        assert_eq!(result["attested"], true);
3942        assert!(result.get("attested_reason").is_none());
3943        let id = uuid::Uuid::parse_str(result["id"].as_str().unwrap()).unwrap();
3944        let report = store.verify_attestation(Galaxy::Codex, id).unwrap();
3945        assert!(report.attested, "{:?}", report.breaks);
3946        assert!(report.signature_valid, "{:?}", report.breaks);
3947        assert!(report.matches_head, "{:?}", report.breaks);
3948        assert!(report.memory_present);
3949        assert!(report.breaks.is_empty());
3950
3951        // Stale path: rewrite the content out from under the attestation —
3952        // signature still verifies, head no longer matches (updates ride
3953        // the revisions chain, not re-attestation).
3954        let mut memory = store.get(Galaxy::Codex, id).unwrap().unwrap();
3955        memory.content = "edited after attestation".to_string();
3956        memory.metadata.content_hash = wm_memory::content_hash(&memory.content);
3957        store.put(Galaxy::Codex, &memory).unwrap();
3958        let stale = store.verify_attestation(Galaxy::Codex, id).unwrap();
3959        assert!(stale.attested);
3960        assert!(stale.signature_valid);
3961        assert!(!stale.matches_head);
3962
3963        // Scan sees exactly the one attested create.
3964        let scanned = store.scan_attestations().unwrap();
3965        assert_eq!(scanned.len(), 1);
3966        assert_eq!(scanned[0].memory_id, id.to_string());
3967    }
3968
3969    #[tokio::test]
3970    async fn memory_batch_create_attests_each_item() {
3971        const TEST_KEY: &str = "0bd1c44170ca3d916648a983dcdb8583d22f2da5b29fdd5ede4b38e805435577";
3972        let store = test_store();
3973        let tool = MemoryBatchCreateTool::with_attestation_key(
3974            store.clone(),
3975            None,
3976            None,
3977            Some(TEST_KEY.to_string()),
3978        );
3979        let mut ctx = Context::new(BrainWave::Gamma);
3980        let result = tool
3981            .call(
3982                &mut ctx,
3983                json!({"items": [{"content": "batch one"}, {"content": "batch two"}]}),
3984            )
3985            .await
3986            .unwrap();
3987        assert_eq!(result["attested_count"], 2);
3988        assert_eq!(store.scan_attestations().unwrap().len(), 2);
3989
3990        // Keyless batch: zero attested, creates still succeed.
3991        let tool = MemoryBatchCreateTool::with_attestation_key(store.clone(), None, None, None);
3992        let result = tool
3993            .call(&mut ctx, json!({"items": [{"content": "batch three"}]}))
3994            .await
3995            .unwrap();
3996        assert_eq!(result["attested_count"], 0);
3997        assert_eq!(result["count"], 1);
3998    }
3999
4000    #[tokio::test]
4001    async fn memory_batch_create_mirrors_into_episodic_lane() {
4002        let store = test_store();
4003        let tool = MemoryBatchCreateTool::new(store.clone(), None, None);
4004        let mut ctx = Context::new(BrainWave::Gamma);
4005        let result = tool
4006            .call(
4007                &mut ctx,
4008                json!({
4009                    "items": [
4010                        {"content": "batch rust retrieval"},
4011                        {"content": "batch grocery list"}
4012                    ]
4013                }),
4014            )
4015            .await
4016            .unwrap();
4017        assert_eq!(result["status"], "success");
4018        let ids = result["ids"].as_array().unwrap();
4019        let first = uuid::Uuid::parse_str(ids[0].as_str().unwrap()).unwrap();
4020        let hits = store
4021            .episodic()
4022            .search("rust retrieval", 10, false)
4023            .unwrap();
4024        assert_eq!(hits.len(), 1);
4025        assert_eq!(hits[0].record.id, first);
4026    }
4027
4028    #[tokio::test]
4029    async fn memory_list_returns_entries() {
4030        let store = test_store();
4031        let create = MemoryCreateTool::new(store.clone(), None, None);
4032        let mut ctx = Context::new(BrainWave::Gamma);
4033
4034        for i in 0..3 {
4035            create
4036                .call(&mut ctx, json!({"content": format!("item-{i}")}))
4037                .await
4038                .unwrap();
4039        }
4040
4041        let list = MemoryListTool::new(store);
4042        let result = list.call(&mut ctx, json!({"limit": 10})).await.unwrap();
4043        assert_eq!(result["status"], "success");
4044        assert_eq!(result["total"], 3);
4045        assert_eq!(result["returned"], 3);
4046    }
4047
4048    /// API honesty (§8): `offset` and `exclude_tags` are real. Paging
4049    /// addresses the VISIBLE surface — private memories and excluded tags
4050    /// never consume page slots.
4051    #[tokio::test]
4052    async fn memory_list_offset_and_exclude_tags_page_visible_surface() {
4053        let store = test_store();
4054        let mut ctx = Context::new(BrainWave::Gamma);
4055
4056        for i in 0..5 {
4057            let mut m = wm_memory::Memory::new(wm_core::Galaxy::Codex, format!("page note {i}"));
4058            if i == 1 {
4059                m.metadata.tags = vec!["noise".into()];
4060            }
4061            if i == 3 {
4062                m.metadata.is_private = true;
4063            }
4064            store.put(wm_core::Galaxy::Codex, &m).unwrap();
4065        }
4066
4067        let list = MemoryListTool::new(store);
4068
4069        // Baseline: private memory and the excluded tag drop out of the
4070        // visible surface; the response discloses matched vs returned.
4071        let all = list
4072            .call(
4073                &mut ctx,
4074                json!({"galaxy": "codex", "limit": 50, "exclude_tags": ["noise"]}),
4075            )
4076            .await
4077            .unwrap();
4078        assert_eq!(all["total"], 5, "total counts the whole galaxy");
4079        assert_eq!(all["matched"], 3, "private + excluded are invisible");
4080        assert_eq!(all["returned"], 3);
4081        assert_eq!(all["offset"], 0);
4082
4083        // Page 1 + page 2 partition the visible surface without overlap.
4084        let page1 = list
4085            .call(
4086                &mut ctx,
4087                json!({"galaxy": "codex", "limit": 2, "offset": 0, "exclude_tags": ["noise"]}),
4088            )
4089            .await
4090            .unwrap();
4091        assert_eq!(page1["returned"], 2);
4092        let page2 = list
4093            .call(
4094                &mut ctx,
4095                json!({"galaxy": "codex", "limit": 2, "offset": 2, "exclude_tags": ["noise"]}),
4096            )
4097            .await
4098            .unwrap();
4099        assert_eq!(
4100            page2["returned"], 1,
4101            "matched is 3 — the tail page is short"
4102        );
4103        assert_eq!(page2["offset"], 2);
4104
4105        let ids_of = |v: &Value| -> Vec<String> {
4106            v["memories"]
4107                .as_array()
4108                .unwrap()
4109                .iter()
4110                .filter_map(|m| m["id"].as_str().map(String::from))
4111                .collect()
4112        };
4113        let (p1, p2, everything) = (ids_of(&page1), ids_of(&page2), ids_of(&all));
4114        assert_eq!(p1.len(), 2);
4115        let mut union = p1;
4116        union.extend(p2);
4117        let mut sorted_union = union.clone();
4118        sorted_union.sort();
4119        let mut sorted_all = everything;
4120        sorted_all.sort();
4121        assert_eq!(sorted_union, sorted_all, "pages must partition the surface");
4122    }
4123
4124    /// Provenance contract (sessions-galaxy attribution fix, 2026-08-29):
4125    /// memory.create defaults to agent/0.7 — a "user" claim must be
4126    /// deliberate, and trust is derived from the claimed class, never
4127    /// caller-chosen.
4128    #[tokio::test]
4129    async fn memory_create_stamps_provenance_by_claim() {
4130        let store = test_store();
4131        let create = MemoryCreateTool::new(store.clone(), None, None);
4132        let mut ctx = Context::new(BrainWave::Gamma);
4133
4134        let silent = create
4135            .call(&mut ctx, json!({"content": "no claim"}))
4136            .await
4137            .unwrap();
4138        assert_eq!(silent["source"], "agent");
4139        assert!((silent["source_trust"].as_f64().unwrap() - 0.7).abs() < 1e-5);
4140
4141        let claimed = create
4142            .call(
4143                &mut ctx,
4144                json!({"content": "user dictated this", "source": "user"}),
4145            )
4146            .await
4147            .unwrap();
4148        assert_eq!(claimed["source"], "user");
4149        assert!((claimed["source_trust"].as_f64().unwrap() - 1.0).abs() < 1e-5);
4150
4151        let custom = create
4152            .call(&mut ctx, json!({"content": "web import", "source": "web"}))
4153            .await
4154            .unwrap();
4155        assert_eq!(custom["source"], "web");
4156        assert!((custom["source_trust"].as_f64().unwrap() - 0.7).abs() < 1e-5);
4157
4158        let fetch = |id: &str| {
4159            store
4160                .get(wm_core::Galaxy::Codex, uuid::Uuid::parse_str(id).unwrap())
4161                .expect("stored")
4162                .expect("present")
4163        };
4164        assert_eq!(
4165            fetch(silent["id"].as_str().unwrap()).metadata.source,
4166            "agent"
4167        );
4168        assert_eq!(
4169            fetch(claimed["id"].as_str().unwrap()).metadata.source,
4170            "user"
4171        );
4172    }
4173
4174    #[tokio::test]
4175    async fn gnosis_returns_system_info() {
4176        let store = test_store();
4177        let tool = GnosisTool::new(store);
4178        let mut ctx = Context::new(BrainWave::Gamma);
4179        let result = tool.call(&mut ctx, json!({})).await.unwrap();
4180        assert_eq!(result["status"], "success");
4181        assert!(result["version"].is_string());
4182    }
4183
4184    #[tokio::test]
4185    async fn memory_delete_removes_entry() {
4186        let store = test_store();
4187        let create = MemoryCreateTool::new(store.clone(), None, None);
4188        let mut ctx = Context::new(BrainWave::Gamma);
4189
4190        let result = create
4191            .call(&mut ctx, json!({"content": "to be deleted"}))
4192            .await
4193            .unwrap();
4194        let id = result["id"].as_str().unwrap();
4195
4196        let delete = MemoryDeleteTool::new(store.clone(), None);
4197        let result = delete.call(&mut ctx, json!({"id": id})).await.unwrap();
4198        assert_eq!(result["status"], "success");
4199
4200        let read = MemoryReadTool::new(store);
4201        let result = read.call(&mut ctx, json!({"id": id})).await.unwrap();
4202        assert_eq!(result["status"], "not_found");
4203    }
4204
4205    #[tokio::test]
4206    async fn memory_delete_without_galaxy_resolves_across_memory_galaxies() {
4207        let store = test_store();
4208        let create = MemoryCreateTool::new(store.clone(), None, None);
4209        let mut ctx = Context::new(BrainWave::Gamma);
4210
4211        // A session memory lives in the sessions galaxy, not codex.
4212        let result = create
4213            .call(
4214                &mut ctx,
4215                json!({"content": "session decision", "galaxy": "sessions"}),
4216            )
4217            .await
4218            .unwrap();
4219        let id = result["id"].as_str().unwrap();
4220
4221        // No explicit galaxy: the delete must still find and remove it.
4222        let delete = MemoryDeleteTool::new(store.clone(), None);
4223        let result = delete.call(&mut ctx, json!({"id": id})).await.unwrap();
4224        assert_eq!(result["status"], "success");
4225        assert!(
4226            result["galaxies"]
4227                .as_array()
4228                .unwrap()
4229                .contains(&json!("sessions"))
4230        );
4231
4232        let read = MemoryReadTool::new(store.clone());
4233        let result = read
4234            .call(&mut ctx, json!({"id": id, "galaxy": "sessions"}))
4235            .await
4236            .unwrap();
4237        assert_eq!(result["status"], "not_found");
4238    }
4239
4240    #[tokio::test]
4241    async fn memory_delete_explicit_galaxy_does_not_miss_other_galaxies() {
4242        let store = test_store();
4243        let create = MemoryCreateTool::new(store.clone(), None, None);
4244        let mut ctx = Context::new(BrainWave::Gamma);
4245
4246        let result = create
4247            .call(
4248                &mut ctx,
4249                json!({"content": "in sessions", "galaxy": "sessions"}),
4250            )
4251            .await
4252            .unwrap();
4253        let id = result["id"].as_str().unwrap();
4254
4255        // Explicit wrong galaxy: truthful not_found with a hint, record intact.
4256        let delete = MemoryDeleteTool::new(store.clone(), None);
4257        let result = delete
4258            .call(&mut ctx, json!({"id": id, "galaxy": "codex"}))
4259            .await
4260            .unwrap();
4261        assert_eq!(result["status"], "not_found");
4262        assert!(result["hint"].is_string());
4263
4264        let read = MemoryReadTool::new(store.clone());
4265        let result = read
4266            .call(&mut ctx, json!({"id": id, "galaxy": "sessions"}))
4267            .await
4268            .unwrap();
4269        assert_eq!(result["status"], "success");
4270    }
4271
4272    #[tokio::test]
4273    async fn memory_query_filters_by_tags() {
4274        let store = test_store();
4275        let create = MemoryCreateTool::new(store.clone(), None, None);
4276        let mut ctx = Context::new(BrainWave::Gamma);
4277
4278        create
4279            .call(&mut ctx, json!({"content": "tagged", "tags": ["rust"]}))
4280            .await
4281            .unwrap();
4282        create
4283            .call(&mut ctx, json!({"content": "untagged"}))
4284            .await
4285            .unwrap();
4286
4287        let query = MemoryQueryTool::new(store);
4288        let result = query
4289            .call(&mut ctx, json!({"tags": ["rust"]}))
4290            .await
4291            .unwrap();
4292        assert_eq!(result["status"], "success");
4293        assert_eq!(result["total"], 1);
4294    }
4295
4296    /// API honesty (§8): `created_after` / `created_before` pass through to
4297    /// the store's temporal filter instead of being silently ignored.
4298    #[tokio::test]
4299    async fn memory_query_time_range_passthrough() {
4300        let store = test_store();
4301        let mut ctx = Context::new(BrainWave::Gamma);
4302
4303        let mut old = wm_memory::Memory::new(wm_core::Galaxy::Codex, "old relic".into());
4304        old.metadata.created_at = chrono::Utc::now() - chrono::Duration::days(60);
4305        store.put(wm_core::Galaxy::Codex, &old).unwrap();
4306        let mut recent = wm_memory::Memory::new(wm_core::Galaxy::Codex, "recent note".into());
4307        recent.metadata.created_at = chrono::Utc::now() - chrono::Duration::hours(1);
4308        store.put(wm_core::Galaxy::Codex, &recent).unwrap();
4309
4310        let query = MemoryQueryTool::new(store);
4311        let cutoff = (chrono::Utc::now() - chrono::Duration::days(1))
4312            .to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
4313
4314        let only_recent = query
4315            .call(&mut ctx, json!({"created_after": cutoff}))
4316            .await
4317            .unwrap();
4318        assert_eq!(only_recent["total"], 1);
4319        assert_eq!(only_recent["memories"][0]["content_preview"], "recent note");
4320        assert_eq!(
4321            only_recent["time_range"]["created_after"], cutoff,
4322            "the applied time range must be disclosed"
4323        );
4324
4325        let only_old = query
4326            .call(&mut ctx, json!({"created_before": cutoff}))
4327            .await
4328            .unwrap();
4329        assert_eq!(only_old["total"], 1);
4330        assert_eq!(only_old["memories"][0]["content_preview"], "old relic");
4331
4332        // Both bounds compose.
4333        let both = query
4334            .call(
4335                &mut ctx,
4336                json!({
4337                    "created_after": (chrono::Utc::now() - chrono::Duration::days(90)).to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4338                    "created_before": cutoff,
4339                }),
4340            )
4341            .await
4342            .unwrap();
4343        assert_eq!(both["total"], 1);
4344        assert_eq!(both["memories"][0]["content_preview"], "old relic");
4345
4346        // Malformed bounds are a loud InvalidArgs, never a silent no-filter.
4347        let bad = query
4348            .call(&mut ctx, json!({"created_after": "not-a-timestamp"}))
4349            .await;
4350        assert!(bad.is_err(), "invalid RFC 3339 must be refused");
4351    }
4352
4353    #[tokio::test]
4354    async fn memory_vector_search_by_embedding() {
4355        let store = test_store();
4356        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4357
4358        // Add some vectors directly
4359        {
4360            let mut vs = vector_store.lock().unwrap();
4361            vs.add(uuid::Uuid::new_v4(), Galaxy::Codex, vec![1.0, 0.0, 0.0]);
4362            vs.add(uuid::Uuid::new_v4(), Galaxy::Codex, vec![0.9, 0.1, 0.0]);
4363            vs.add(uuid::Uuid::new_v4(), Galaxy::Research, vec![0.0, 1.0, 0.0]);
4364        }
4365
4366        let tool = MemoryVectorSearchTool::new(store, vector_store);
4367        let mut ctx = Context::new(BrainWave::Gamma);
4368
4369        // Search for vectors similar to [1, 0, 0]
4370        let result = tool
4371            .call(&mut ctx, json!({"embedding": [1.0, 0.0, 0.0], "limit": 2}))
4372            .await
4373            .unwrap();
4374        assert_eq!(result["status"], "success");
4375        assert_eq!(result["total"], 2);
4376    }
4377
4378    #[tokio::test]
4379    async fn memory_vector_search_missing_args() {
4380        let store = test_store();
4381        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4382
4383        let tool = MemoryVectorSearchTool::new(store, vector_store);
4384        let mut ctx = Context::new(BrainWave::Gamma);
4385
4386        let result = tool.call(&mut ctx, json!({"limit": 5})).await;
4387        assert!(result.is_err());
4388    }
4389
4390    #[tokio::test]
4391    async fn wm_routes_vector_search_to_memory_vector_search() {
4392        let store = test_store();
4393        let registry = test_registry_with(&store);
4394        let registry = register_meta_tools(
4395            &registry,
4396            &store,
4397            std::sync::Arc::new(std::sync::RwLock::new(
4398                embedding_router::ShadowModeStats::default(),
4399            )),
4400        );
4401
4402        let wm = registry.get("wm").unwrap();
4403        let mut ctx = Context::new(BrainWave::Gamma);
4404        let result = wm
4405            .call(
4406                &mut ctx,
4407                json!({"route": "memory.vector.search", "args": {"embedding": [1.0, 0.0, 0.0]}}),
4408            )
4409            .await
4410            .unwrap();
4411
4412        assert_eq!(result["status"], "success");
4413        assert_eq!(result["_wm_route"]["tool"], "memory.vector.search");
4414    }
4415
4416    #[tokio::test]
4417    async fn wm_routes_shadow_report_inside_meta_tool() {
4418        // The MCP boundary only exposes the `wm` meta-tool, so observability
4419        // tools must be reachable through it. Regression test: `nlu.shadow_report`
4420        // was top-level-only and returned "Unknown tool" via wm(route=...).
4421        let store = test_store();
4422        let registry = test_registry_with(&store);
4423        let registry = register_meta_tools(
4424            &registry,
4425            &store,
4426            std::sync::Arc::new(std::sync::RwLock::new(
4427                embedding_router::ShadowModeStats::default(),
4428            )),
4429        );
4430
4431        let wm = registry.get("wm").unwrap();
4432        let mut ctx = Context::new(BrainWave::Gamma);
4433        let result = wm
4434            .call(&mut ctx, json!({"route": "nlu.shadow_report"}))
4435            .await
4436            .unwrap();
4437
4438        assert_eq!(result["_wm_route"]["tool"], "nlu.shadow_report");
4439        assert!(
4440            result.get("total_queries").is_some(),
4441            "expected shadow report payload"
4442        );
4443    }
4444
4445    #[tokio::test]
4446    async fn memory_associate_and_find() {
4447        let store = test_store();
4448        let create = MemoryCreateTool::new(store.clone(), None, None);
4449        let mut ctx = Context::new(BrainWave::Gamma);
4450
4451        let r1 = create
4452            .call(&mut ctx, json!({"content": "source mem"}))
4453            .await
4454            .unwrap();
4455        let r2 = create
4456            .call(&mut ctx, json!({"content": "target mem"}))
4457            .await
4458            .unwrap();
4459        let id1 = r1["id"].as_str().unwrap();
4460        let id2 = r2["id"].as_str().unwrap();
4461
4462        let assoc = MemoryAssociateTool::new(store.clone());
4463        let result = assoc
4464            .call(
4465                &mut ctx,
4466                json!({"source": id1, "target": id2, "weight": 0.8}),
4467            )
4468            .await
4469            .unwrap();
4470        assert_eq!(result["status"], "success");
4471
4472        let find = MemoryAssociationsTool::new(store);
4473        let result = find
4474            .call(&mut ctx, json!({"id": id1, "direction": "from"}))
4475            .await
4476            .unwrap();
4477        assert_eq!(result["status"], "success");
4478        assert_eq!(result["returned"], 1);
4479    }
4480
4481    #[tokio::test]
4482    async fn karma_report_shows_entries() {
4483        let tmp = tempfile::tempdir().unwrap();
4484        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
4485        let ledger = Arc::new(KarmaLedger::new(store).unwrap());
4486
4487        // Record a few entries
4488        ledger.record("test_tool", false, 0, true).unwrap();
4489        ledger.record("wasteful_tool", true, 0, true).unwrap();
4490
4491        let tool = KarmaReportTool::new(ledger);
4492        let mut ctx = Context::new(BrainWave::Gamma);
4493        let result = tool.call(&mut ctx, json!({"limit": 5})).await.unwrap();
4494        assert_eq!(result["status"], "success");
4495        assert_eq!(result["entry_count"], 2);
4496        assert_eq!(result["recent_entries"].as_array().unwrap().len(), 2);
4497    }
4498
4499    #[tokio::test]
4500    async fn dharma_status_returns_homeostasis() {
4501        let gate = Arc::new(DharmaGate::default());
4502        let tool = DharmaStatusTool::new(gate);
4503        let mut ctx = Context::new(BrainWave::Gamma);
4504        let result = tool.call(&mut ctx, json!({})).await.unwrap();
4505        assert_eq!(result["status"], "success");
4506        assert!(result["homeostasis"]["health_score"].is_f64());
4507        assert!(result["sutras"]["ahimsa"].is_string());
4508    }
4509
4510    #[tokio::test]
4511    async fn wm_routes_remember_to_memory_create() {
4512        let store = test_store();
4513        let registry = test_registry_with(&store);
4514        let registry = register_meta_tools(
4515            &registry,
4516            &store,
4517            std::sync::Arc::new(std::sync::RwLock::new(
4518                embedding_router::ShadowModeStats::default(),
4519            )),
4520        );
4521
4522        let wm = registry.get("wm").unwrap();
4523        let mut ctx = Context::new(BrainWave::Gamma);
4524        let result = wm
4525            .call(
4526                &mut ctx,
4527                json!({"thought": "remember that the API uses X-User-Id headers"}),
4528            )
4529            .await
4530            .unwrap();
4531
4532        assert_eq!(result["status"], "success");
4533        assert_eq!(result["_wm_route"]["tool"], "memory.create");
4534        assert!(result["id"].is_string());
4535    }
4536
4537    #[tokio::test]
4538    async fn wm_explicit_route() {
4539        let store = test_store();
4540        let registry = test_registry_with(&store);
4541        let registry = register_meta_tools(
4542            &registry,
4543            &store,
4544            std::sync::Arc::new(std::sync::RwLock::new(
4545                embedding_router::ShadowModeStats::default(),
4546            )),
4547        );
4548
4549        let wm = registry.get("wm").unwrap();
4550        let mut ctx = Context::new(BrainWave::Gamma);
4551        let result = wm
4552            .call(
4553                &mut ctx,
4554                json!({
4555                    "route": "gnosis"
4556                }),
4557            )
4558            .await
4559            .unwrap();
4560
4561        assert_eq!(result["status"], "success");
4562        assert_eq!(result["_wm_route"]["tool"], "gnosis");
4563    }
4564
4565    #[tokio::test]
4566    async fn wm_no_input_returns_error() {
4567        let store = test_store();
4568        let registry = test_registry_with(&store);
4569        let registry = register_meta_tools(
4570            &registry,
4571            &store,
4572            std::sync::Arc::new(std::sync::RwLock::new(
4573                embedding_router::ShadowModeStats::default(),
4574            )),
4575        );
4576
4577        let wm = registry.get("wm").unwrap();
4578        let mut ctx = Context::new(BrainWave::Gamma);
4579        let result = wm.call(&mut ctx, json!({})).await.unwrap();
4580
4581        assert_eq!(result["status"], "error");
4582    }
4583
4584    #[tokio::test]
4585    async fn wm_missing_route_echoes_received_keys() {
4586        // When a client drops the routing fields in transit, the error must
4587        // show which keys DID arrive so the drop is diagnosable in one step
4588        // (observed live 2026-08-23: two requests arrived with payload keys
4589        // but no route; the bare message cost six probes to isolate).
4590        let store = test_store();
4591        let registry = test_registry_with(&store);
4592        let registry = register_meta_tools(
4593            &registry,
4594            &store,
4595            std::sync::Arc::new(std::sync::RwLock::new(
4596                embedding_router::ShadowModeStats::default(),
4597            )),
4598        );
4599
4600        let wm = registry.get("wm").unwrap();
4601        let mut ctx = Context::new(BrainWave::Gamma);
4602        let result = wm
4603            .call(
4604                &mut ctx,
4605                json!({"content": "x", "turn_type": "summary", "importance": 0.5}),
4606            )
4607            .await
4608            .unwrap();
4609
4610        assert_eq!(result["status"], "error");
4611        let message = result["message"].as_str().unwrap();
4612        assert!(
4613            message.contains("received argument keys"),
4614            "error must disclose received keys, got: {message}"
4615        );
4616        for key in ["content", "turn_type", "importance"] {
4617            assert!(
4618                message.contains(key),
4619                "error must list received key '{key}', got: {message}"
4620            );
4621        }
4622        // Empty-input case stays bare (no keys to list).
4623        let empty = wm.call(&mut ctx, json!({})).await.unwrap();
4624        assert!(
4625            !empty["message"]
4626                .as_str()
4627                .unwrap()
4628                .contains("received argument keys: ["),
4629            "empty input must not list keys, got: {}",
4630            empty["message"]
4631        );
4632    }
4633
4634    #[tokio::test]
4635    async fn wm_unknown_tool_returns_error() {
4636        let store = test_store();
4637        let registry = test_registry_with(&store);
4638        let registry = register_meta_tools(
4639            &registry,
4640            &store,
4641            std::sync::Arc::new(std::sync::RwLock::new(
4642                embedding_router::ShadowModeStats::default(),
4643            )),
4644        );
4645
4646        let wm = registry.get("wm").unwrap();
4647        let mut ctx = Context::new(BrainWave::Gamma);
4648        let result = wm
4649            .call(&mut ctx, json!({"route": "nonexistent.tool"}))
4650            .await
4651            .unwrap();
4652
4653        assert_eq!(result["status"], "error");
4654        assert!(result["message"].as_str().unwrap().contains("Unknown tool"));
4655    }
4656
4657    #[tokio::test]
4658    async fn wm_missing_arg_returns_hint() {
4659        let store = test_store();
4660        let registry = test_registry_with(&store);
4661        let registry = register_meta_tools(
4662            &registry,
4663            &store,
4664            std::sync::Arc::new(std::sync::RwLock::new(
4665                embedding_router::ShadowModeStats::default(),
4666            )),
4667        );
4668
4669        let wm = registry.get("wm").unwrap();
4670        let mut ctx = Context::new(BrainWave::Gamma);
4671
4672        // Route to memory.read without providing id
4673        let result = wm
4674            .call(&mut ctx, json!({"route": "memory.read"}))
4675            .await
4676            .unwrap();
4677
4678        assert_eq!(result["status"], "error");
4679        assert!(
4680            result["message"]
4681                .as_str()
4682                .unwrap()
4683                .contains("Missing required argument")
4684        );
4685        assert!(result["hint"].as_str().unwrap().contains("uuid"));
4686    }
4687
4688    #[tokio::test]
4689    async fn wm_auto_route_missing_arg_returns_hint() {
4690        let store = test_store();
4691        let registry = test_registry_with(&store);
4692        let registry = register_meta_tools(
4693            &registry,
4694            &store,
4695            std::sync::Arc::new(std::sync::RwLock::new(
4696                embedding_router::ShadowModeStats::default(),
4697            )),
4698        );
4699
4700        let wm = registry.get("wm").unwrap();
4701        let mut ctx = Context::new(BrainWave::Gamma);
4702
4703        // "recall" routes to memory.read but no UUID provided
4704        let result = wm
4705            .call(&mut ctx, json!({"thought": "recall"}))
4706            .await
4707            .unwrap();
4708
4709        assert_eq!(result["status"], "error");
4710        assert!(result["hint"].as_str().unwrap().contains("uuid"));
4711    }
4712
4713    #[tokio::test]
4714    async fn wm_routes_karma_to_karma_report() {
4715        let tmp = tempfile::tempdir().unwrap();
4716        let store = Arc::new(MemoryStore::open_default(tmp.path()).unwrap());
4717        let ledger = Arc::new(KarmaLedger::new(store.clone()).unwrap());
4718        let gate = Arc::new(DharmaGate::default());
4719
4720        let registry = ToolRegistry::new();
4721        let associations = Arc::new(AssociationStore::open(store.env()).unwrap());
4722        let spiral_tracker =
4723            Arc::new(std::sync::Mutex::new(wm_cognitive::SpiralTracker::default()));
4724        let vector_store = Arc::new(std::sync::Mutex::new(wm_memory::VectorStore::new()));
4725        let registry = register_all(
4726            &registry,
4727            &store,
4728            None,
4729            Some(ledger),
4730            &Some(gate),
4731            None,
4732            &None,
4733            associations,
4734            spiral_tracker,
4735            vector_store,
4736            None,
4737            None,
4738            None,
4739            None,
4740            None,
4741            None,
4742            None,
4743            std::sync::Arc::new(std::sync::Mutex::new(None)),
4744            None,
4745            None,
4746            None,
4747            expansion::RegistryPersistenceMode::Normal,
4748        );
4749        let registry = register_meta_tools(
4750            &registry,
4751            &store,
4752            std::sync::Arc::new(std::sync::RwLock::new(
4753                embedding_router::ShadowModeStats::default(),
4754            )),
4755        );
4756
4757        let wm = registry.get("wm").unwrap();
4758        let mut ctx = Context::new(BrainWave::Gamma);
4759        let result = wm
4760            .call(&mut ctx, json!({"thought": "show me the karma report"}))
4761            .await
4762            .unwrap();
4763
4764        assert_eq!(result["status"], "success");
4765        assert_eq!(result["_wm_route"]["tool"], "karma.report");
4766    }
4767
4768    /// Build a registry with the wm meta-tool wired to a real DispatchPipeline,
4769    /// so inner tool calls are governance-gated (destructive confirm, etc.).
4770    fn test_registry_with_pipeline(
4771        store: &Arc<MemoryStore>,
4772    ) -> (ToolRegistry, Arc<DispatchPipeline>) {
4773        let registry = test_registry_with(store);
4774        let pipeline = Arc::new(DispatchPipeline::with_defaults());
4775        let (registry, _router) = register_meta_tools_with_router(
4776            &registry,
4777            store,
4778            std::sync::Arc::new(std::sync::RwLock::new(
4779                embedding_router::ShadowModeStats::default(),
4780            )),
4781            Some(pipeline.clone()),
4782        );
4783        (registry, pipeline)
4784    }
4785
4786    #[tokio::test]
4787    async fn wm_route_destructive_without_confirm_blocked_by_pipeline() {
4788        let store = test_store();
4789        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4790
4791        let wm = registry.get("wm").unwrap();
4792        let mut ctx = Context::new(BrainWave::Gamma);
4793        let result = wm
4794            .call(
4795                &mut ctx,
4796                json!({"route": "memory.delete", "args": {"id": "00000000-0000-0000-0000-000000000001"}}),
4797            )
4798            .await
4799            .unwrap();
4800
4801        assert_eq!(result["status"], "error");
4802        assert!(
4803            result["error"].as_str().unwrap().contains("destructive"),
4804            "expected destructive-gate message, got: {result}"
4805        );
4806        assert!(result["error"].as_str().unwrap().contains("confirm"));
4807    }
4808
4809    #[tokio::test]
4810    async fn wm_route_destructive_with_confirm_proceeds() {
4811        let store = test_store();
4812        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4813
4814        // Create a real memory to delete.
4815        let memory = Memory::new(Galaxy::Codex, "delete me via wm route".into());
4816        let id = memory.metadata.id;
4817        store.put(Galaxy::Codex, &memory).unwrap();
4818
4819        let wm = registry.get("wm").unwrap();
4820        let mut ctx = Context::new(BrainWave::Gamma);
4821        let result = wm
4822            .call(
4823                &mut ctx,
4824                json!({"route": "memory.delete", "args": {"id": id.to_string(), "galaxy": "codex", "confirm": true}}),
4825            )
4826            .await
4827            .unwrap();
4828
4829        assert_eq!(result["status"], "success");
4830        assert_eq!(result["_wm_route"]["tool"], "memory.delete");
4831        assert!(store.get(Galaxy::Codex, id).unwrap().is_none());
4832    }
4833
4834    #[tokio::test]
4835    async fn wm_thought_cannot_reach_destructive_tool() {
4836        let store = test_store();
4837        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4838
4839        let wm = registry.get("wm").unwrap();
4840        let mut ctx = Context::new(BrainWave::Gamma);
4841        // "delete memory <uuid>" routes to memory.delete via NLU — must be
4842        // structurally blocked even with confirm present in extracted payload.
4843        let result = wm
4844            .call(
4845                &mut ctx,
4846                json!({"thought": "delete memory 00000000-0000-0000-0000-000000000001"}),
4847            )
4848            .await
4849            .unwrap();
4850
4851        assert_eq!(result["status"], "error");
4852        assert!(
4853            result["message"]
4854                .as_str()
4855                .unwrap()
4856                .contains("cannot be reached via natural language"),
4857            "expected NLU hard-block message, got: {result}"
4858        );
4859    }
4860
4861    #[tokio::test]
4862    async fn wm_thought_cannot_reach_destructive_tool_even_with_confirm() {
4863        let store = test_store();
4864        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4865
4866        let wm = registry.get("wm").unwrap();
4867        let mut ctx = Context::new(BrainWave::Gamma);
4868        // An LLM that guesses the confirm requirement (and supplies the id)
4869        // must still be blocked — NLU routing is structurally barred from
4870        // destructive tools.
4871        let result = wm
4872            .call(
4873                &mut ctx,
4874                json!({"thought": "delete memory 00000000-0000-0000-0000-000000000001", "args": {"confirm": true, "id": "00000000-0000-0000-0000-000000000001"}}),
4875            )
4876            .await
4877            .unwrap();
4878
4879        assert_eq!(result["status"], "error");
4880        assert!(
4881            result["message"]
4882                .as_str()
4883                .unwrap()
4884                .contains("cannot be reached via natural language")
4885        );
4886    }
4887
4888    /// P0 acceptance test: every destructive tool in the registry is blocked
4889    /// when reached via natural-language routing (thought=). This sweeps all
4890    /// registered tools, filters to those with `destructive: true`, and
4891    /// verifies each one returns the hard-block error — not just memory.delete.
4892    #[tokio::test]
4893    async fn nlu_cannot_reach_any_destructive_tool() {
4894        let store = test_store();
4895        let (registry, _pipeline) = test_registry_with_pipeline(&store);
4896        let wm = registry.get("wm").unwrap();
4897
4898        // Collect all destructive tool names from the registry (excluding
4899        // `wm` itself, which is pure — it routes, it doesn't mutate).
4900        let destructive_tools: Vec<String> = registry
4901            .all_ref()
4902            .iter()
4903            .filter(|t| t.effects().destructive)
4904            .map(|t| t.name().to_string())
4905            .collect();
4906
4907        assert!(
4908            !destructive_tools.is_empty(),
4909            "registry must contain at least one destructive tool for this test to be meaningful"
4910        );
4911
4912        let mut ctx = Context::new(BrainWave::Gamma);
4913        for tool_name in &destructive_tools {
4914            // Attempt 1: bare tool name as thought with confirm=true.
4915            // If NLU routes to the destructive tool, the structural gate must
4916            // block it. If NLU routes elsewhere, that's also fine.
4917            let result = wm
4918                .call(
4919                    &mut ctx,
4920                    json!({
4921                        "thought": tool_name,
4922                        "args": {"confirm": true}
4923                    }),
4924                )
4925                .await
4926                .unwrap();
4927
4928            // A destructive tool must never EXECUTE via NLU. Fuzzy routing
4929            // may land on a different, non-destructive tool that succeeds —
4930            // that is fine. What must not happen is success from a tool
4931            // whose own effects are destructive.
4932            let routed_tool = result
4933                .get("_wm_route")
4934                .and_then(|r| r.get("tool"))
4935                .and_then(|t| t.as_str())
4936                .unwrap_or("");
4937            let resolved_destructive = registry
4938                .get(routed_tool)
4939                .is_some_and(|t| t.effects().destructive);
4940            assert!(
4941                result["status"] != "success" || !resolved_destructive,
4942                "destructive tool '{tool_name}' executed via NLU (resolved as '{routed_tool}') — structural gate failed"
4943            );
4944
4945            // If NLU did route to the destructive tool, the gate message must
4946            // be present (proving the structural block, not just a miss).
4947            if routed_tool == tool_name {
4948                assert!(
4949                    result
4950                        .get("message")
4951                        .and_then(|m| m.as_str())
4952                        .is_some_and(|m| m.contains("cannot be reached via natural language")),
4953                    "destructive tool '{tool_name}' was routed to but gate message missing: {result}"
4954                );
4955            }
4956
4957            // Attempt 2: natural-language phrasing that might route to the
4958            // destructive tool (e.g., "rollback the transaction"). This
4959            // catches the case where the tool name itself doesn't match NLU
4960            // profiles but a natural phrase does.
4961            let nl_phrase = match tool_name.as_str() {
4962                "memory.delete" => "delete memory 00000000-0000-0000-0000-000000000001",
4963                "transaction.rollback" => "rollback the transaction",
4964                "galaxy.purge" => "purge galaxy codex",
4965                "galaxy.transfer" => "transfer galaxy codex to archive",
4966                "galaxy.restore" => "restore galaxy codex from snapshot",
4967                "memory.consolidate" => "consolidate memories in codex",
4968                "memory.deduplicate" => "deduplicate memories in codex",
4969                "karma.purge" => "purge karma ledger",
4970                "system.flush" => "flush low importance memories",
4971                "galaxy.cold_rotate" => "rotate telemetry noise to cold storage",
4972                _ => tool_name.as_str(),
4973            };
4974            let result2 = wm
4975                .call(&mut ctx, json!({"thought": nl_phrase}))
4976                .await
4977                .unwrap();
4978
4979            let routed_tool2 = result2
4980                .get("_wm_route")
4981                .and_then(|r| r.get("tool"))
4982                .and_then(|t| t.as_str())
4983                .unwrap_or("");
4984            let resolved_destructive2 = registry
4985                .get(routed_tool2)
4986                .is_some_and(|t| t.effects().destructive);
4987            assert!(
4988                result2["status"] != "success" || !resolved_destructive2,
4989                "destructive tool '{tool_name}' executed via NLU phrase '{nl_phrase}' (resolved as '{routed_tool2}') — structural gate failed"
4990            );
4991            if routed_tool2 == tool_name {
4992                assert!(
4993                    result2
4994                        .get("message")
4995                        .and_then(|m| m.as_str())
4996                        .is_some_and(|m| m.contains("cannot be reached via natural language")),
4997                    "destructive tool '{tool_name}' was routed to via '{nl_phrase}' but gate message missing: {result2}"
4998                );
4999            }
5000        }
5001    }
5002
5003    #[tokio::test]
5004    async fn nlu_abstention_returns_error_for_unmatched_query() {
5005        let store = test_store();
5006        let (registry, _pipeline) = test_registry_with_pipeline(&store);
5007        let wm = registry.get("wm").unwrap();
5008        let mut ctx = Context::new(BrainWave::Gamma);
5009
5010        // A nonsense query that won't match any tool profile — should
5011        // abstain and return an error with the abstention flag set.
5012        let result = wm
5013            .call(&mut ctx, json!({"thought": "xyzzy quux blargh frobnicate"}))
5014            .await
5015            .unwrap();
5016
5017        assert_eq!(result["status"], "error");
5018        assert!(
5019            result
5020                .get("_wm_route")
5021                .and_then(|r| r.get("abstained"))
5022                .and_then(serde_json::Value::as_bool)
5023                .unwrap_or(false),
5024            "expected abstained=true, got: {result}"
5025        );
5026        assert!(
5027            result["message"]
5028                .as_str()
5029                .unwrap()
5030                .contains("Could not confidently match"),
5031            "expected abstention message, got: {result}"
5032        );
5033    }
5034
5035    #[tokio::test]
5036    async fn nlu_abstention_does_not_fire_for_explicit_route() {
5037        let store = test_store();
5038        let (registry, _pipeline) = test_registry_with_pipeline(&store);
5039        let wm = registry.get("wm").unwrap();
5040        let mut ctx = Context::new(BrainWave::Gamma);
5041
5042        // Explicit route to gnosis should work even though gnosis is the
5043        // fallback tool — abstention only applies to NLU routing.
5044        let result = wm.call(&mut ctx, json!({"route": "gnosis"})).await.unwrap();
5045
5046        assert_eq!(result["status"], "success");
5047        assert!(
5048            !result
5049                .get("_wm_route")
5050                .and_then(|r| r.get("abstained"))
5051                .and_then(serde_json::Value::as_bool)
5052                .unwrap_or(false),
5053            "explicit route should not abstain, got: {result}"
5054        );
5055    }
5056
5057    /// Deterministic fake embedder — exercises the embedding router path
5058    /// without the stub auto-detect kicking in (backend name != "stub").
5059    struct FakeVecEmbedder;
5060
5061    impl wm_memory::Embedder for FakeVecEmbedder {
5062        fn embed_batch(&self, texts: &[&str]) -> wm_core::Result<Vec<Vec<f32>>> {
5063            Ok(texts
5064                .iter()
5065                .map(|t| {
5066                    let mut v = vec![0.0_f32; 16];
5067                    for (i, b) in t.bytes().take(16).enumerate() {
5068                        v[i] = f32::from(b) / 255.0;
5069                    }
5070                    v
5071                })
5072                .collect())
5073        }
5074        fn dimension(&self) -> usize {
5075            16
5076        }
5077        fn is_available(&self) -> bool {
5078            true
5079        }
5080        fn backend_name(&self) -> &'static str {
5081            "fake"
5082        }
5083    }
5084
5085    #[tokio::test]
5086    async fn wm_classify_async_routes_off_thread_with_embedding_router() {
5087        let store = test_store();
5088        let registry = test_registry_with(&store);
5089        let shadow = std::sync::Arc::new(std::sync::RwLock::new(
5090            embedding_router::ShadowModeStats::default(),
5091        ));
5092        let router = embedding_router::EmbeddingRouter::with_descriptions(
5093            Box::new(FakeVecEmbedder),
5094            embedding_router::tool_descriptions(),
5095        )
5096        .expect("fake-embedder router should build");
5097        let mut meta = WmMetaTool::with_router_shadow_stats_and_pipeline(
5098            std::sync::Arc::new(registry),
5099            wm_memory::create_embedder(),
5100            shadow,
5101            None,
5102        );
5103        meta.embedding_router = Some(std::sync::Arc::new(router));
5104
5105        // Runs through spawn_blocking; on the current-thread test runtime this
5106        // proves the classification path is runtime-agnostic and completes.
5107        let (tool, conf, emb) = meta.classify_async("remember the meeting notes").await;
5108        assert!(!tool.is_empty());
5109        assert!(conf >= 0.0);
5110        assert!(
5111            emb.is_some(),
5112            "query embedding should be returned for OATS reuse"
5113        );
5114    }
5115
5116    #[tokio::test]
5117    async fn tools_list_shows_all() {
5118        let store = test_store();
5119        let registry = test_registry_with(&store);
5120        let registry = register_meta_tools(
5121            &registry,
5122            &store,
5123            std::sync::Arc::new(std::sync::RwLock::new(
5124                embedding_router::ShadowModeStats::default(),
5125            )),
5126        );
5127
5128        let list = registry.get("tools.list").unwrap();
5129        let mut ctx = Context::new(BrainWave::Gamma);
5130        let result = list.call(&mut ctx, json!({})).await.unwrap();
5131
5132        assert_eq!(result["status"], "success");
5133        assert!(result["total"].as_u64().unwrap() >= 7);
5134    }
5135
5136    #[tokio::test]
5137    async fn tools_list_exposes_curated_argument_schemas() {
5138        let store = test_store();
5139        let registry = test_registry_with(&store);
5140        let registry = register_meta_tools(
5141            &registry,
5142            &store,
5143            std::sync::Arc::new(std::sync::RwLock::new(
5144                embedding_router::ShadowModeStats::default(),
5145            )),
5146        );
5147
5148        let list = registry.get("tools.list").unwrap();
5149        let mut ctx = Context::new(BrainWave::Gamma);
5150        let result = list.call(&mut ctx, json!({})).await.unwrap();
5151
5152        let tools = result["tools"].as_array().unwrap();
5153        let create = tools
5154            .iter()
5155            .find(|t| t["name"] == "memory.create")
5156            .expect("tools.list must include memory.create");
5157        let schema = &create["input_schema"];
5158        assert_eq!(schema["type"], "object");
5159        assert!(
5160            schema["properties"].get("content").is_some(),
5161            "memory.create schema must describe content, got: {schema}"
5162        );
5163        assert!(
5164            schema["required"]
5165                .as_array()
5166                .unwrap()
5167                .iter()
5168                .any(|r| r == "content"),
5169            "memory.create schema must require content"
5170        );
5171
5172        let rollback = tools
5173            .iter()
5174            .find(|t| t["name"] == "transaction.rollback")
5175            .expect("tools.list must include transaction.rollback");
5176        assert!(
5177            rollback["input_schema"]["required"]
5178                .as_array()
5179                .unwrap()
5180                .iter()
5181                .any(|r| r == "confirm"),
5182            "transaction.rollback schema must require confirm"
5183        );
5184
5185        // MCP annotations derived from EffectRow.
5186        let annotations = &create["annotations"];
5187        assert_eq!(annotations["readOnlyHint"], false, "memory.create writes");
5188        assert_eq!(annotations["destructiveHint"], false);
5189        assert_eq!(
5190            rollback["annotations"]["destructiveHint"], true,
5191            "transaction.rollback is destructive"
5192        );
5193        let list_tool = tools
5194            .iter()
5195            .find(|t| t["name"] == "memory.list")
5196            .expect("tools.list must include memory.list");
5197        assert_eq!(
5198            list_tool["annotations"]["readOnlyHint"], true,
5199            "memory.list is read-only"
5200        );
5201    }
5202
5203    #[tokio::test]
5204    async fn tools_list_filters_by_brain_wave() {
5205        let store = test_store();
5206        let registry = test_registry_with(&store);
5207        let registry = register_meta_tools(
5208            &registry,
5209            &store,
5210            std::sync::Arc::new(std::sync::RwLock::new(
5211                embedding_router::ShadowModeStats::default(),
5212            )),
5213        );
5214
5215        let list = registry.get("tools.list").unwrap();
5216
5217        // Gamma: all tools available
5218        let mut ctx_gamma = Context::new(BrainWave::Gamma);
5219        let result_gamma = list.call(&mut ctx_gamma, json!({})).await.unwrap();
5220        let gamma_count = result_gamma["total"].as_u64().unwrap();
5221        assert!(gamma_count >= 7);
5222
5223        // Alpha: only read-only tools (no writes, no expensive)
5224        let mut ctx_alpha = Context::new(BrainWave::Alpha);
5225        let result_alpha = list.call(&mut ctx_alpha, json!({})).await.unwrap();
5226        let alpha_count = result_alpha["total"].as_u64().unwrap();
5227        assert!(alpha_count < gamma_count);
5228        assert!(alpha_count > 0);
5229
5230        // Delta: no tools available
5231        let mut ctx_delta = Context::new(BrainWave::Delta);
5232        let result_delta = list.call(&mut ctx_delta, json!({})).await.unwrap();
5233        assert_eq!(result_delta["total"], 0);
5234    }
5235
5236    #[tokio::test]
5237    async fn gnosis_includes_brain_wave_and_tool_count() {
5238        let store = test_store();
5239        let registry = test_registry_with(&store);
5240        let registry = register_meta_tools(
5241            &registry,
5242            &store,
5243            std::sync::Arc::new(std::sync::RwLock::new(
5244                embedding_router::ShadowModeStats::default(),
5245            )),
5246        );
5247
5248        let gnosis = registry.get("gnosis").unwrap();
5249        let mut ctx = Context::new(BrainWave::Gamma);
5250        let result = gnosis.call(&mut ctx, json!({})).await.unwrap();
5251
5252        assert_eq!(result["status"], "success");
5253        assert_eq!(result["brain_wave"], "Gamma");
5254        assert!(result["available_tools"].as_u64().unwrap() >= 9);
5255    }
5256
5257    #[tokio::test]
5258    async fn gnosis_available_tools_is_total_registered() {
5259        let store = test_store();
5260        let registry = test_registry_with(&store);
5261        let registry = register_meta_tools(
5262            &registry,
5263            &store,
5264            std::sync::Arc::new(std::sync::RwLock::new(
5265                embedding_router::ShadowModeStats::default(),
5266            )),
5267        );
5268
5269        let gnosis = registry.get("gnosis").unwrap();
5270
5271        // available_tools is now a static count of registered tools,
5272        // not brain-wave-dependent. It should be the same in all states.
5273        let mut ctx_gamma = Context::new(BrainWave::Gamma);
5274        let result_gamma = gnosis.call(&mut ctx_gamma, json!({})).await.unwrap();
5275        let gamma_tools = result_gamma["available_tools"].as_u64().unwrap();
5276
5277        let mut ctx_delta = Context::new(BrainWave::Delta);
5278        let result_delta = gnosis.call(&mut ctx_delta, json!({})).await.unwrap();
5279        let delta_tools = result_delta["available_tools"].as_u64().unwrap();
5280
5281        assert_eq!(gamma_tools, delta_tools);
5282        assert!(
5283            gamma_tools >= 9,
5284            "expected at least 9 registered tools, got {gamma_tools}"
5285        );
5286    }
5287
5288    #[tokio::test]
5289    async fn expansion_brings_tool_count_to_50() {
5290        let store = test_store();
5291        let registry = test_registry_with(&store);
5292        let registry = register_meta_tools(
5293            &registry,
5294            &store,
5295            std::sync::Arc::new(std::sync::RwLock::new(
5296                embedding_router::ShadowModeStats::default(),
5297            )),
5298        );
5299
5300        let list = registry.get("tools.list").unwrap();
5301        let mut ctx = Context::new(BrainWave::Gamma);
5302        let result = list.call(&mut ctx, json!({})).await.unwrap();
5303
5304        let total = result["total"].as_u64().unwrap();
5305        assert!(
5306            total >= 50,
5307            "Expected 50+ tools after expansion, got {total}"
5308        );
5309    }
5310
5311    // ── NLU Router Expansion Tests ─────────────────────────────────────
5312
5313    #[tokio::test]
5314    async fn nlu_routes_consolidate() {
5315        let (tool, conf) = WmMetaTool::classify("consolidate memories in codex");
5316        assert_eq!(tool, "memory.consolidate");
5317        assert!(conf > 0.0);
5318    }
5319
5320    #[tokio::test]
5321    async fn nlu_routes_decay() {
5322        let (tool, conf) = WmMetaTool::classify("decay old memories");
5323        assert_eq!(tool, "memory.decay");
5324        assert!(conf > 0.0);
5325    }
5326
5327    #[tokio::test]
5328    async fn nlu_routes_batch_read() {
5329        let (tool, conf) = WmMetaTool::classify("batch read these memories");
5330        assert_eq!(tool, "memory.batch_read");
5331        assert!(conf > 0.0);
5332    }
5333
5334    #[tokio::test]
5335    async fn nlu_routes_update() {
5336        let (tool, conf) = WmMetaTool::classify("update memory tags");
5337        assert_eq!(tool, "memory.update");
5338        assert!(conf > 0.0);
5339    }
5340
5341    #[tokio::test]
5342    async fn nlu_routes_tag() {
5343        let (tool, conf) = WmMetaTool::classify("add tag to memory");
5344        assert_eq!(tool, "memory.tag");
5345        assert!(conf > 0.0);
5346    }
5347
5348    #[tokio::test]
5349    async fn nlu_routes_memory_stats() {
5350        let (tool, conf) = WmMetaTool::classify("memory stats for codex");
5351        assert_eq!(tool, "memory.stats");
5352        assert!(conf > 0.0);
5353    }
5354
5355    #[tokio::test]
5356    async fn nlu_routes_hybrid_recall() {
5357        let (tool, conf) = WmMetaTool::classify("hybrid recall for rust");
5358        assert_eq!(tool, "memory.hybrid_recall");
5359        assert!(conf > 0.0);
5360    }
5361
5362    #[tokio::test]
5363    async fn nlu_routes_count() {
5364        let (tool, conf) = WmMetaTool::classify("count memories in codex");
5365        assert_eq!(tool, "memory.count");
5366        assert!(conf > 0.0);
5367    }
5368
5369    #[tokio::test]
5370    async fn nlu_routes_tags() {
5371        let (tool, conf) = WmMetaTool::classify("list tags in codex");
5372        assert_eq!(tool, "memory.tags");
5373        assert!(conf > 0.0);
5374    }
5375
5376    #[tokio::test]
5377    async fn nlu_routes_associate_mine() {
5378        let (tool, conf) = WmMetaTool::classify("mine associations in codex");
5379        assert_eq!(tool, "memory.associate_mine");
5380        assert!(conf > 0.0);
5381    }
5382
5383    #[tokio::test]
5384    async fn nlu_routes_session_start() {
5385        let (tool, conf) = WmMetaTool::classify("start session research");
5386        assert_eq!(tool, "session.start");
5387        assert!(conf > 0.0);
5388    }
5389
5390    #[tokio::test]
5391    async fn nlu_routes_session_end() {
5392        let (tool, conf) = WmMetaTool::classify("end session 12345");
5393        assert_eq!(tool, "session.end");
5394        assert!(conf > 0.0);
5395    }
5396
5397    #[tokio::test]
5398    async fn nlu_routes_session_list() {
5399        let (tool, conf) = WmMetaTool::classify("list sessions");
5400        assert_eq!(tool, "session.list");
5401        assert!(conf > 0.0);
5402    }
5403
5404    #[tokio::test]
5405    async fn nlu_routes_citta_status() {
5406        let (tool, conf) = WmMetaTool::classify("citta status");
5407        assert_eq!(tool, "citta.status");
5408        assert!(conf > 0.0);
5409    }
5410
5411    #[tokio::test]
5412    async fn nlu_routes_citta_reflect() {
5413        let (tool, conf) = WmMetaTool::classify("reflect on recent events");
5414        assert_eq!(tool, "citta.reflect");
5415        assert!(conf > 0.0);
5416    }
5417
5418    #[tokio::test]
5419    async fn nlu_routes_coherence() {
5420        let (tool, conf) = WmMetaTool::classify("check coherence");
5421        assert_eq!(tool, "citta.coherence");
5422        assert!(conf > 0.0);
5423    }
5424
5425    #[tokio::test]
5426    async fn nlu_routes_dream_status() {
5427        let (tool, conf) = WmMetaTool::classify("dream cycle status");
5428        assert_eq!(tool, "dream.status");
5429        assert!(conf > 0.0);
5430    }
5431
5432    #[tokio::test]
5433    async fn nlu_routes_dream_trigger() {
5434        let (tool, conf) = WmMetaTool::classify("trigger dream cycle");
5435        assert_eq!(tool, "dream.trigger");
5436        assert!(conf > 0.0);
5437    }
5438
5439    #[tokio::test]
5440    async fn nlu_routes_effectiveness() {
5441        let (tool, conf) = WmMetaTool::classify("tool effectiveness report");
5442        assert_eq!(tool, "tools.effectiveness_report");
5443        assert!(conf > 0.0);
5444    }
5445
5446    #[tokio::test]
5447    async fn nlu_routes_retire() {
5448        let (tool, conf) = WmMetaTool::classify("retire tool memory.old");
5449        assert_eq!(tool, "tools.retire");
5450        assert!(conf > 0.0);
5451    }
5452
5453    #[tokio::test]
5454    async fn nlu_routes_pattern_search() {
5455        let (tool, conf) = WmMetaTool::classify("pattern search for rust");
5456        assert_eq!(tool, "pattern.search");
5457        assert!(conf > 0.0);
5458    }
5459
5460    #[tokio::test]
5461    async fn nlu_routes_salience() {
5462        let (tool, conf) = WmMetaTool::classify("salience spotlight");
5463        assert_eq!(tool, "salience.spotlight");
5464        assert!(conf > 0.0);
5465    }
5466
5467    #[tokio::test]
5468    async fn nlu_routes_serendipity() {
5469        let (tool, conf) = WmMetaTool::classify("serendipity surface");
5470        assert_eq!(tool, "serendipity.surface");
5471        assert!(conf > 0.0);
5472    }
5473
5474    #[tokio::test]
5475    async fn nlu_routes_constellation_detect() {
5476        let (tool, conf) = WmMetaTool::classify("detect clusters");
5477        assert_eq!(tool, "constellation.detect");
5478        assert!(conf > 0.0);
5479    }
5480
5481    #[tokio::test]
5482    async fn nlu_routes_constellation_list() {
5483        let (tool, conf) = WmMetaTool::classify("list constellations");
5484        assert_eq!(tool, "constellation.list");
5485        assert!(conf > 0.0);
5486    }
5487
5488    #[tokio::test]
5489    async fn nlu_routes_galaxy_stats() {
5490        let (tool, conf) = WmMetaTool::classify("galaxy stats");
5491        assert_eq!(tool, "galaxy.stats");
5492        assert!(conf > 0.0);
5493    }
5494
5495    #[tokio::test]
5496    async fn nlu_routes_galaxy_export() {
5497        let (tool, conf) = WmMetaTool::classify("export galaxy codex");
5498        assert_eq!(tool, "galaxy.export");
5499        assert!(conf > 0.0);
5500    }
5501
5502    #[tokio::test]
5503    async fn nlu_routes_galaxy_import() {
5504        let (tool, conf) = WmMetaTool::classify("import galaxy codex");
5505        assert_eq!(tool, "galaxy.import");
5506        assert!(conf > 0.0);
5507    }
5508
5509    #[tokio::test]
5510    async fn nlu_routes_karma_history() {
5511        let (tool, conf) = WmMetaTool::classify("karma history");
5512        assert_eq!(tool, "karma.history");
5513        assert!(conf > 0.0);
5514    }
5515
5516    #[tokio::test]
5517    async fn nlu_routes_karma_clear() {
5518        let (tool, conf) = WmMetaTool::classify("clear karma");
5519        assert_eq!(tool, "karma.clear");
5520        assert!(conf > 0.0);
5521    }
5522
5523    #[tokio::test]
5524    async fn nlu_routes_dharma_rules() {
5525        let (tool, conf) = WmMetaTool::classify("dharma rules");
5526        assert_eq!(tool, "dharma.rules");
5527        assert!(conf > 0.0);
5528    }
5529
5530    #[tokio::test]
5531    async fn nlu_routes_dharma_audit() {
5532        let (tool, conf) = WmMetaTool::classify("dharma audit");
5533        assert_eq!(tool, "dharma.audit");
5534        assert!(conf > 0.0);
5535    }
5536
5537    #[tokio::test]
5538    async fn nlu_routes_dharma_profiles() {
5539        let (tool, conf) = WmMetaTool::classify("dharma profiles");
5540        assert_eq!(tool, "dharma.profiles");
5541        assert!(conf > 0.0);
5542    }
5543
5544    #[tokio::test]
5545    async fn nlu_routes_agent_register() {
5546        let (tool, conf) = WmMetaTool::classify("register agent worker-1");
5547        assert_eq!(tool, "agent.register");
5548        assert!(conf > 0.0);
5549    }
5550
5551    #[tokio::test]
5552    async fn nlu_routes_agent_list() {
5553        let (tool, conf) = WmMetaTool::classify("list agents");
5554        assert_eq!(tool, "agent.list");
5555        assert!(conf > 0.0);
5556    }
5557
5558    #[tokio::test]
5559    async fn nlu_routes_agent_heartbeat() {
5560        let (tool, conf) = WmMetaTool::classify("heartbeat for agent");
5561        assert_eq!(tool, "agent.heartbeat");
5562        assert!(conf > 0.0);
5563    }
5564
5565    #[tokio::test]
5566    async fn nlu_routes_task_distribute() {
5567        let (tool, conf) = WmMetaTool::classify("distribute task analyze data");
5568        assert_eq!(tool, "task.distribute");
5569        assert!(conf > 0.0);
5570    }
5571
5572    #[tokio::test]
5573    async fn nlu_routes_task_status() {
5574        let (tool, conf) = WmMetaTool::classify("task status");
5575        assert_eq!(tool, "task.status");
5576        assert!(conf > 0.0);
5577    }
5578
5579    #[tokio::test]
5580    async fn nlu_routes_system_health() {
5581        let (tool, conf) = WmMetaTool::classify("system health check");
5582        assert_eq!(tool, "system.health");
5583        assert!(conf > 0.0);
5584    }
5585
5586    #[tokio::test]
5587    async fn nlu_routes_system_config() {
5588        let (tool, conf) = WmMetaTool::classify("system config");
5589        assert_eq!(tool, "system.config");
5590        assert!(conf > 0.0);
5591    }
5592
5593    #[tokio::test]
5594    async fn nlu_routes_system_flush() {
5595        let (tool, conf) = WmMetaTool::classify("flush old memories");
5596        assert_eq!(tool, "system.flush");
5597        assert!(conf > 0.0);
5598    }
5599
5600    #[tokio::test]
5601    async fn nlu_routes_memory_nearby() {
5602        let (tool, conf) = WmMetaTool::classify("nearby memories in codex");
5603        assert_eq!(tool, "memory.nearby");
5604        assert!(conf > 0.0);
5605    }
5606
5607    #[tokio::test]
5608    async fn nlu_routes_empty_to_gnosis() {
5609        let (tool, conf) = WmMetaTool::classify("");
5610        assert_eq!(tool, "gnosis");
5611        assert_eq!(conf, 0.0);
5612    }
5613
5614    #[tokio::test]
5615    async fn nlu_routes_unknown_to_gnosis() {
5616        let (tool, conf) = WmMetaTool::classify("xyzzy frobnicate");
5617        assert_eq!(tool, "gnosis");
5618        assert_eq!(conf, 0.0);
5619    }
5620
5621    #[tokio::test]
5622    async fn nlu_extract_payload_memory_search() {
5623        let (param, value) =
5624            WmMetaTool::extract_payload("search for rust patterns", "memory.search").unwrap();
5625        assert_eq!(param, "query");
5626        assert_eq!(value, "rust patterns");
5627    }
5628
5629    #[tokio::test]
5630    async fn nlu_extract_payload_session_start() {
5631        // Regression: the payload key was "name", which session.start never
5632        // reads — natural-language session starts silently created
5633        // "Untitled Session" entries.
5634        let (param, value) =
5635            WmMetaTool::extract_payload("start session research", "session.start").unwrap();
5636        assert_eq!(param, "title");
5637        assert_eq!(value, "research");
5638    }
5639
5640    #[tokio::test]
5641    async fn nlu_extract_payload_agent_register() {
5642        let (param, value) =
5643            WmMetaTool::extract_payload("register agent worker-1", "agent.register").unwrap();
5644        assert_eq!(param, "name");
5645        assert_eq!(value, "worker-1");
5646    }
5647
5648    #[tokio::test]
5649    async fn nlu_extract_payload_task_distribute() {
5650        let (param, value) =
5651            WmMetaTool::extract_payload("distribute task analyze data", "task.distribute").unwrap();
5652        assert_eq!(param, "task");
5653        assert_eq!(value, "analyze data");
5654    }
5655
5656    #[tokio::test]
5657    async fn nlu_count_unique_patterns() {
5658        // Verify we have 30+ unique routing targets
5659        let inputs = [
5660            "remember",
5661            "recall",
5662            "list memories",
5663            "delete memory",
5664            "search",
5665            "query",
5666            "associate",
5667            "associations",
5668            "consolidate",
5669            "decay",
5670            "batch read",
5671            "update memory",
5672            "tag memory",
5673            "memory stats",
5674            "hybrid recall",
5675            "count memories",
5676            "list tags",
5677            "mine associations",
5678            "start session",
5679            "checkpoint",
5680            "recall session",
5681            "end session",
5682            "list sessions",
5683            "citta status",
5684            "reflect",
5685            "coherence",
5686            "dream status",
5687            "trigger dream",
5688            "effectiveness",
5689            "retire tool",
5690            "pattern search",
5691            "salience",
5692            "serendipity",
5693            "detect clusters",
5694            "list constellations",
5695            "galaxy stats",
5696            "export galaxy",
5697            "import galaxy",
5698            "karma",
5699            "karma history",
5700            "clear karma",
5701            "dharma rules",
5702            "dharma audit",
5703            "dharma profiles",
5704            "dharma",
5705            "register agent",
5706            "list agents",
5707            "heartbeat",
5708            "distribute task",
5709            "task status",
5710            "system health",
5711            "system config",
5712            "flush",
5713            "tools",
5714            "nearby memories",
5715        ];
5716        let mut tools: std::collections::HashSet<&str> = std::collections::HashSet::new();
5717        for input in &inputs {
5718            let (tool, _) = WmMetaTool::classify(input);
5719            tools.insert(tool);
5720        }
5721        // Should have 30+ unique tool targets
5722        assert!(
5723            tools.len() >= 30,
5724            "Expected 30+ unique NLU targets, got {}",
5725            tools.len()
5726        );
5727    }
5728
5729    #[tokio::test]
5730    async fn nlu_routes_shadow_report() {
5731        let (tool, conf) = WmMetaTool::classify("shadow mode disagreement report");
5732        assert_eq!(tool, "nlu.shadow_report");
5733        assert!(conf > 0.0);
5734    }
5735
5736    #[tokio::test]
5737    async fn nlu_routes_oats_report() {
5738        let (tool, conf) = WmMetaTool::classify("oats disagreement nlu router");
5739        assert_eq!(tool, "nlu.shadow_report");
5740        assert!(conf > 0.0);
5741    }
5742
5743    // ── Q34 glyph wire (decode seam) ─────────────────────────────────
5744
5745    #[test]
5746    fn glyph_roundtrip_known_codes() {
5747        let raw = json!({"route": "memory.search", "args": {"query": "x", "limit": 3}});
5748        let encoded = encode_glyph("memory.search", &json!({"query": "x", "limit": 3}));
5749        assert_eq!(encoded["r"], "Ms");
5750        assert_eq!(encoded["a"]["q"], "x");
5751        assert_eq!(encoded["a"]["n"], 3);
5752        let decoded = decode_glyph(&encoded).expect("glyph input must decode");
5753        assert_eq!(decoded["route"], raw["route"]);
5754        assert_eq!(decoded["args"]["query"], "x");
5755        assert_eq!(decoded["args"]["limit"], 3);
5756    }
5757
5758    #[test]
5759    fn glyph_unknown_codes_pass_through() {
5760        let weird = json!({"r": "not-a-code", "a": {"zzz": 1}});
5761        assert!(decode_glyph(&weird).is_none(), "unknown route code refuses");
5762        let partial = json!({"r": "Ms", "a": {"zzz": 1}});
5763        let decoded = decode_glyph(&partial).expect("known route decodes");
5764        assert_eq!(decoded["args"]["zzz"], 1, "unknown arg code passes through");
5765        assert_eq!(decode_glyph(&json!({"thought": "hi"})), None);
5766    }
5767
5768    #[test]
5769    fn glyph_book_covers_measured_routes() {
5770        // The book must cover the routes the 33% measurement was run on.
5771        for route in [
5772            "memory.search",
5773            "memory.create",
5774            "session.record",
5775            "session.continuity",
5776            "dharma.escalate",
5777            "graph.walk",
5778            "tools.list",
5779            "citta.status",
5780        ] {
5781            assert!(
5782                glyph_lookup(GLYPH_ROUTES, route).is_some(),
5783                "missing {route}"
5784            );
5785        }
5786    }
5787
5788    #[test]
5789    fn glyph_logographic_ideograms_decode_losslessly() {
5790        // Test single-token logographic Chinese ideograms
5791        let search_call = json!({
5792            "r": "忆",
5793            "a": {
5794                "问": "auth failure",
5795                "数": 5
5796            }
5797        });
5798        let decoded = decode_glyph(&search_call).expect("logographic search decodes");
5799        assert_eq!(decoded["route"], "memory.search");
5800        assert_eq!(decoded["args"]["query"], "auth failure");
5801        assert_eq!(decoded["args"]["limit"], 5);
5802
5803        let checkpoint_call = json!({
5804            "r": "契",
5805            "a": {
5806                "文": "v9.3 milestone reached"
5807            }
5808        });
5809        let decoded_cp = decode_glyph(&checkpoint_call).expect("checkpoint decodes");
5810        assert_eq!(decoded_cp["route"], "session.checkpoint");
5811        assert_eq!(decoded_cp["args"]["content"], "v9.3 milestone reached");
5812
5813        let status_call = json!({"r": "心", "a": {}});
5814        let decoded_st = decode_glyph(&status_call).expect("citta status decodes");
5815        assert_eq!(decoded_st["route"], "citta.status");
5816    }
5817
5818    #[test]
5819    fn lkep_expression_decodes_and_normalizes() {
5820        // String expressions
5821        let (route, args) =
5822            decode_lkep(&json!("忆(问=\"deadlock\", 数=3)")).expect("LKEP string decodes");
5823        assert_eq!(route, "memory.search");
5824        assert_eq!(args["query"], "deadlock");
5825        assert_eq!(args["limit"], 3);
5826
5827        // Positional shorthand
5828        let (route2, args2) =
5829            decode_lkep(&json!("忆: memory corruption")).expect("colon syntax decodes");
5830        assert_eq!(route2, "memory.search");
5831        assert_eq!(args2["query"], "memory corruption");
5832
5833        // Bare route
5834        let (route3, args3) = decode_lkep(&json!("律")).expect("bare route decodes");
5835        assert_eq!(route3, "dharma.rules");
5836        assert_eq!(args3, json!({}));
5837
5838        // Root ideogram map
5839        let (route4, args4) =
5840            decode_lkep(&json!({"忆": "fast lookup"})).expect("root ideogram decodes");
5841        assert_eq!(route4, "memory.search");
5842        assert_eq!(args4["query"], "fast lookup");
5843    }
5844}