Skip to main content

wm_tools/
lib.rs

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