Skip to main content

wm_tools/
lib.rs

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