Skip to main content

wm_tools/
lib.rs

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