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