Skip to main content

wm_tools/
lib.rs

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