Skip to main content

ai_memory/cli/
namespace.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory namespace` subcommand — operator-facing CRUD for the
5//! per-namespace standard policy memory pointer (issue #800 Crack 1).
6//!
7//! Before this verb shipped, operators had to drop into an MCP-stdio
8//! JSON-RPC dance to call `memory_namespace_set_standard` /
9//! `memory_namespace_get_standard` / `memory_namespace_clear_standard`
10//! because there was no CLI surface for these tools. That friction was
11//! the single largest reason Batman Forms 2 + 6 stayed dormant on most
12//! installs (see [`docs/batman-active-mode.md`](../../docs/batman-active-mode.md)).
13//!
14//! Three verbs:
15//!
16//! * `set-standard`   — point a namespace at a memory whose
17//!                      `metadata.governance` carries the policy.
18//!                      Optionally merge a `--governance` JSON blob
19//!                      into that memory in the same call.
20//! * `get-standard`   — print the current standard pointer (and the
21//!                      typed governance policy if a standard is set).
22//! * `clear-standard` — drop the pointer for a namespace.
23//!
24//! All three are thin wrappers around the existing MCP handlers in
25//! `src/mcp/tools/namespace.rs`. Output is human-friendly by default
26//! and JSON when `--json` is passed on the top-level CLI.
27
28use crate::cli::CliOutput;
29use crate::db;
30use crate::mcp::{
31    handle_namespace_clear_standard, handle_namespace_get_standard, handle_namespace_set_standard,
32};
33use crate::models::field_names;
34use anyhow::{Context, Result};
35use clap::{Args, Subcommand};
36use serde_json::{Value, json};
37use std::path::Path;
38
39#[derive(Args)]
40pub struct NamespaceArgs {
41    #[command(subcommand)]
42    pub action: NamespaceAction,
43}
44
45#[derive(Subcommand)]
46pub enum NamespaceAction {
47    /// Bind a namespace to a standard memory. The memory's
48    /// `metadata.governance` carries the per-namespace
49    /// `GovernancePolicy` (auto_atomise / auto_atomise_mode /
50    /// auto_classify_kind / max_reflection_depth /
51    /// write/promote/delete/approver/inherit).
52    ///
53    /// Equivalent to the `memory_namespace_set_standard` MCP tool. If
54    /// `--governance` is provided, the JSON object is merged into the
55    /// standard memory's `metadata.governance` before the bind — the
56    /// merge preserves keys outside the typed `GovernancePolicy`
57    /// surface (e.g. `require_approval_above_depth`).
58    SetStandard {
59        /// Target namespace (e.g. `main`, `ai-memory-mcp`).
60        #[arg(long)]
61        namespace: String,
62        /// Standard memory id (UUID). Create the memory first with
63        /// `ai-memory store` and capture its id.
64        #[arg(long)]
65        id: String,
66        /// Optional parent namespace; sets the inheritance chain
67        /// `namespace_meta.parent_namespace` in the same write.
68        #[arg(long)]
69        parent: Option<String>,
70        /// Optional governance JSON blob to merge into the standard
71        /// memory's `metadata.governance`. Example:
72        /// `{"auto_atomise":true,"auto_atomise_mode":"synchronous",
73        /// "auto_classify_kind":"regex_then_llm",
74        /// "max_reflection_depth":3,"write":"owner","promote":"any",
75        /// "delete":"owner","approver":"human","inherit":true}`.
76        #[arg(long)]
77        governance: Option<String>,
78    },
79    /// Print the current standard pointer for a namespace. With
80    /// `--inherit`, walks the parent chain and returns every standard
81    /// up to the root.
82    GetStandard {
83        #[arg(long)]
84        namespace: String,
85        /// Walk the parent-namespace chain and return the full
86        /// inherited standards list (most-general-first).
87        #[arg(long)]
88        inherit: bool,
89    },
90    /// Drop the standard pointer for a namespace. The standard memory
91    /// itself is not deleted; only the `namespace_meta.standard_id`
92    /// pointer is cleared.
93    ClearStandard {
94        #[arg(long)]
95        namespace: String,
96    },
97    /// Convenience: build the canonical Batman-active `GovernancePolicy`
98    /// JSON blob and print it to stdout. Pipe into `set-standard
99    /// --governance "$(...)"` or paste into a `memory_store
100    /// metadata.governance` field.
101    BatmanPolicy {
102        /// Atomise threshold (cl100k tokens). Below this no atomisation
103        /// fires.
104        #[arg(long, default_value_t = 512)]
105        atomise_threshold: u32,
106        /// Per-atom token ceiling.
107        #[arg(long, default_value_t = 256)]
108        atom_max_tokens: u32,
109        /// Reflection depth cap (Task 2 / recursive-learning).
110        #[arg(long, default_value_t = 3)]
111        max_reflection_depth: u32,
112        /// `regex_only` (cheaper) or `regex_then_llm` (full Form 6).
113        #[arg(long, default_value = "regex_then_llm")]
114        classify_mode: String,
115    },
116}
117
118pub fn run(
119    db_path: &Path,
120    args: NamespaceArgs,
121    json_out: bool,
122    out: &mut CliOutput<'_>,
123) -> Result<()> {
124    match args.action {
125        NamespaceAction::SetStandard {
126            namespace,
127            id,
128            parent,
129            governance,
130        } => set_standard(
131            db_path,
132            &namespace,
133            &id,
134            parent.as_deref(),
135            governance.as_deref(),
136            json_out,
137            out,
138        ),
139        NamespaceAction::GetStandard { namespace, inherit } => {
140            get_standard(db_path, &namespace, inherit, json_out, out)
141        }
142        NamespaceAction::ClearStandard { namespace } => {
143            clear_standard(db_path, &namespace, json_out, out)
144        }
145        NamespaceAction::BatmanPolicy {
146            atomise_threshold,
147            atom_max_tokens,
148            max_reflection_depth,
149            classify_mode,
150        } => batman_policy(
151            atomise_threshold,
152            atom_max_tokens,
153            max_reflection_depth,
154            &classify_mode,
155            out,
156        ),
157    }
158}
159
160fn set_standard(
161    db_path: &Path,
162    namespace: &str,
163    id: &str,
164    parent: Option<&str>,
165    governance: Option<&str>,
166    json_out: bool,
167    out: &mut CliOutput<'_>,
168) -> Result<()> {
169    let conn = db::open(db_path)?;
170    let mut params = json!({
171        "namespace": namespace,
172        "id": id,
173    });
174    if let Some(p) = parent {
175        params["parent"] = json!(p);
176    }
177    if let Some(g) = governance {
178        let gov_val: Value =
179            serde_json::from_str(g).context("--governance must be a valid JSON object")?;
180        params[field_names::GOVERNANCE] = gov_val;
181    }
182    let resp = handle_namespace_set_standard(&conn, &params).map_err(|e| anyhow::anyhow!(e))?;
183    emit(out, json_out, &resp, |o, r| {
184        writeln!(
185            o.stdout,
186            "set standard: namespace='{}' standard_id='{}'{}",
187            r["namespace"].as_str().unwrap_or(""),
188            r[field_names::STANDARD_ID].as_str().unwrap_or(""),
189            r.get("parent")
190                .and_then(Value::as_str)
191                .map(|p| format!(" parent='{p}'"))
192                .unwrap_or_default(),
193        )?;
194        if let Some(gov) = r.get(field_names::GOVERNANCE) {
195            writeln!(
196                o.stdout,
197                "governance merged: {}",
198                serde_json::to_string_pretty(gov)?
199            )?;
200        }
201        Ok(())
202    })
203}
204
205fn get_standard(
206    db_path: &Path,
207    namespace: &str,
208    inherit: bool,
209    json_out: bool,
210    out: &mut CliOutput<'_>,
211) -> Result<()> {
212    let conn = db::open(db_path)?;
213    let params = json!({
214        "namespace": namespace,
215        "inherit": inherit,
216    });
217    let resp = handle_namespace_get_standard(&conn, &params).map_err(|e| anyhow::anyhow!(e))?;
218    emit(out, json_out, &resp, |o, r| {
219        if let Some(chain) = r.get("chain").and_then(Value::as_array) {
220            writeln!(
221                o.stdout,
222                "namespace: {}",
223                r["namespace"].as_str().unwrap_or("")
224            )?;
225            writeln!(
226                o.stdout,
227                "chain: {}",
228                chain
229                    .iter()
230                    .filter_map(Value::as_str)
231                    .collect::<Vec<_>>()
232                    .join(" -> ")
233            )?;
234            if let Some(stds) = r.get("standards").and_then(Value::as_array) {
235                writeln!(o.stdout, "standards in chain:")?;
236                for s in stds {
237                    writeln!(
238                        o.stdout,
239                        "  - {}: {}",
240                        s["namespace"].as_str().unwrap_or(""),
241                        s[field_names::STANDARD_ID].as_str().unwrap_or("null")
242                    )?;
243                }
244            }
245        } else if r.get(field_names::STANDARD_ID).map_or(true, Value::is_null) {
246            writeln!(o.stdout, "namespace '{}' has no standard set", namespace)?;
247        } else {
248            writeln!(
249                o.stdout,
250                "namespace: {}\nstandard_id: {}\ntitle: {}",
251                r["namespace"].as_str().unwrap_or(""),
252                r[field_names::STANDARD_ID].as_str().unwrap_or(""),
253                r["title"].as_str().unwrap_or(""),
254            )?;
255            if let Some(gov) = r.get(field_names::GOVERNANCE) {
256                writeln!(
257                    o.stdout,
258                    "governance:\n{}",
259                    serde_json::to_string_pretty(gov)?
260                )?;
261            }
262        }
263        Ok(())
264    })
265}
266
267fn clear_standard(
268    db_path: &Path,
269    namespace: &str,
270    json_out: bool,
271    out: &mut CliOutput<'_>,
272) -> Result<()> {
273    let conn = db::open(db_path)?;
274    let params = json!({ "namespace": namespace });
275    let resp = handle_namespace_clear_standard(&conn, &params).map_err(|e| anyhow::anyhow!(e))?;
276    emit(out, json_out, &resp, |o, r| {
277        writeln!(
278            o.stdout,
279            "{} standard pointer for namespace '{}'",
280            if r["cleared"].as_bool().unwrap_or(false) {
281                "cleared"
282            } else {
283                "no-op (no standard set)"
284            },
285            r["namespace"].as_str().unwrap_or(namespace),
286        )?;
287        Ok(())
288    })
289}
290
291fn batman_policy(
292    atomise_threshold: u32,
293    atom_max_tokens: u32,
294    max_reflection_depth: u32,
295    classify_mode: &str,
296    out: &mut CliOutput<'_>,
297) -> Result<()> {
298    let policy = json!({
299        "auto_atomise": true,
300        "auto_atomise_mode": crate::models::namespace::AUTO_ATOMISE_SYNCHRONOUS,
301        "auto_atomise_threshold_cl100k": atomise_threshold,
302        "auto_atomise_max_atom_tokens": atom_max_tokens,
303        "auto_classify_kind": classify_mode,
304        "max_reflection_depth": max_reflection_depth,
305        "write": "owner",
306        "promote": "any",
307        "delete": "owner",
308        "approver": "human",
309        "inherit": true,
310    });
311    writeln!(out.stdout, "{}", serde_json::to_string_pretty(&policy)?)?;
312    Ok(())
313}
314
315fn emit<F>(out: &mut CliOutput<'_>, json_out: bool, resp: &Value, human: F) -> Result<()>
316where
317    F: FnOnce(&mut CliOutput<'_>, &Value) -> Result<()>,
318{
319    if json_out {
320        writeln!(out.stdout, "{}", serde_json::to_string_pretty(resp)?)?;
321    } else {
322        human(out, resp)?;
323    }
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::cli::test_utils::{TestEnv, seed_memory};
331
332    fn run_action(env: &mut TestEnv, action: NamespaceAction, json_out: bool) -> Result<()> {
333        let db = env.db_path.clone();
334        let mut out = env.output();
335        run(&db, NamespaceArgs { action }, json_out, &mut out)
336    }
337
338    /// Seed a memory to use as a namespace-standard id (the handler
339    /// requires the standard `id` to resolve to an existing memory row).
340    fn seed_standard(env: &TestEnv, ns: &str, title: &str) -> String {
341        seed_memory(&env.db_path, ns, title, "standard body")
342    }
343
344    #[test]
345    fn set_standard_text_output_with_governance() {
346        let mut env = TestEnv::fresh();
347        let id = seed_standard(&env, "team/alpha", "alpha-standard");
348        run_action(
349            &mut env,
350            NamespaceAction::SetStandard {
351                namespace: "team/alpha".into(),
352                id,
353                parent: None,
354                governance: Some(r#"{"write":"owner"}"#.into()),
355            },
356            false,
357        )
358        .expect("set ok");
359        let stdout = env.stdout_str();
360        assert!(stdout.contains("set standard:"), "got: {stdout}");
361        assert!(stdout.contains("governance merged:"), "got: {stdout}");
362    }
363
364    #[test]
365    fn get_standard_text_no_standard() {
366        let mut env = TestEnv::fresh();
367        run_action(
368            &mut env,
369            NamespaceAction::GetStandard {
370                namespace: "empty/ns".into(),
371                inherit: false,
372            },
373            false,
374        )
375        .expect("get ok");
376        assert!(env.stdout_str().contains("has no standard set"));
377    }
378
379    #[test]
380    fn get_standard_text_with_set_standard() {
381        let mut env = TestEnv::fresh();
382        let id = seed_standard(&env, "team/beta", "beta-standard");
383        run_action(
384            &mut env,
385            NamespaceAction::SetStandard {
386                namespace: "team/beta".into(),
387                id,
388                parent: None,
389                governance: Some(r#"{"write":"any"}"#.into()),
390            },
391            false,
392        )
393        .expect("set ok");
394        env.stdout.clear();
395        run_action(
396            &mut env,
397            NamespaceAction::GetStandard {
398                namespace: "team/beta".into(),
399                inherit: false,
400            },
401            false,
402        )
403        .expect("get ok");
404        let stdout = env.stdout_str();
405        assert!(stdout.contains("standard_id:"), "got: {stdout}");
406        assert!(stdout.contains("governance:"), "got: {stdout}");
407    }
408
409    #[test]
410    fn get_standard_text_inherit_chain() {
411        let mut env = TestEnv::fresh();
412        let id = seed_standard(&env, "team/gamma", "gamma-standard");
413        run_action(
414            &mut env,
415            NamespaceAction::SetStandard {
416                namespace: "team/gamma".into(),
417                id,
418                parent: None,
419                governance: None,
420            },
421            false,
422        )
423        .expect("set ok");
424        env.stdout.clear();
425        run_action(
426            &mut env,
427            NamespaceAction::GetStandard {
428                namespace: "team/gamma".into(),
429                inherit: true,
430            },
431            false,
432        )
433        .expect("get ok");
434        let stdout = env.stdout_str();
435        assert!(stdout.contains("chain:"), "got: {stdout}");
436    }
437
438    #[test]
439    fn clear_standard_text_no_op_then_cleared() {
440        let mut env = TestEnv::fresh();
441        // No standard yet → no-op.
442        run_action(
443            &mut env,
444            NamespaceAction::ClearStandard {
445                namespace: "team/delta".into(),
446            },
447            false,
448        )
449        .expect("clear ok");
450        assert!(env.stdout_str().contains("no-op"));
451
452        // Set then clear → cleared.
453        let id = seed_standard(&env, "team/delta", "delta-standard");
454        run_action(
455            &mut env,
456            NamespaceAction::SetStandard {
457                namespace: "team/delta".into(),
458                id,
459                parent: None,
460                governance: None,
461            },
462            false,
463        )
464        .expect("set ok");
465        env.stdout.clear();
466        run_action(
467            &mut env,
468            NamespaceAction::ClearStandard {
469                namespace: "team/delta".into(),
470            },
471            false,
472        )
473        .expect("clear ok");
474        assert!(env.stdout_str().contains("cleared standard pointer"));
475    }
476
477    #[test]
478    fn batman_policy_emits_json_policy() {
479        let mut env = TestEnv::fresh();
480        run_action(
481            &mut env,
482            NamespaceAction::BatmanPolicy {
483                atomise_threshold: 512,
484                atom_max_tokens: 256,
485                max_reflection_depth: 3,
486                classify_mode: "regex_then_llm".into(),
487            },
488            false,
489        )
490        .expect("batman ok");
491        let policy: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
492        assert_eq!(policy["auto_atomise"].as_bool(), Some(true));
493        assert_eq!(policy["max_reflection_depth"].as_u64(), Some(3));
494        assert_eq!(
495            policy["auto_classify_kind"].as_str(),
496            Some("regex_then_llm")
497        );
498    }
499
500    #[test]
501    fn set_standard_json_output() {
502        let mut env = TestEnv::fresh();
503        let id = seed_standard(&env, "team/json", "json-standard");
504        run_action(
505            &mut env,
506            NamespaceAction::SetStandard {
507                namespace: "team/json".into(),
508                id,
509                parent: None,
510                governance: None,
511            },
512            true,
513        )
514        .expect("set ok");
515        let resp: Value = serde_json::from_str(env.stdout_str().trim()).expect("json");
516        assert_eq!(resp["namespace"].as_str(), Some("team/json"));
517    }
518
519    #[test]
520    fn set_standard_invalid_governance_json_errors() {
521        let mut env = TestEnv::fresh();
522        let err = run_action(
523            &mut env,
524            NamespaceAction::SetStandard {
525                namespace: "team/bad".into(),
526                id: "std-b".into(),
527                parent: None,
528                governance: Some("{not json".into()),
529            },
530            false,
531        )
532        .expect_err("must fail");
533        assert!(err.to_string().contains("valid JSON object"), "got: {err}");
534    }
535}