Skip to main content

wm_tools/
lib.rs

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