Skip to main content

wm_tools/
lib.rs

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