Skip to main content

wm_tools/
lib.rs

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