Skip to main content

ai_memory/cli/commands/
reflect.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! v0.7.0 ARCH-3 / FX-C3 (batch2) — `ai-memory reflect` CLI subcommand.
5//!
6//! Closes the three-surface-parity gap on `memory_reflect`. The MCP
7//! tool ([`crate::mcp::handle_reflect`]) and the HTTP route landed
8//! previously; this module wires the CLI surface so operators can
9//! drive the recursive-learning primitive from a terminal without
10//! constructing an MCP-stdio JSON-RPC envelope.
11//!
12//! ## DRY contract
13//!
14//! No business logic lives here — this module is a clap arg-parser
15//! plus an output formatter. The reflect pipeline (depth-cap,
16//! signature, `reflects_on` edge writes) lives in
17//! [`crate::mcp::handle_reflect`]. The MCP, HTTP, and CLI surfaces
18//! share that one implementation.
19//!
20//! ## Signing posture
21//!
22//! Matches the existing CLI convention (Persona / Calibrate / Skill):
23//! the CLI dispatches with `active_keypair = None` and `embedder /
24//! vector_index = None`. Operators who want signed `reflects_on`
25//! edges or LLM-driven dedup must drive `memory_reflect` over the
26//! MCP / HTTP daemon where the resolved keypair + embedder are
27//! ambient. The CLI surface stays unsigned by design so shell scripts
28//! can drive reflections without re-implementing the keypair-load
29//! ceremony.
30
31use crate::models::field_names;
32use anyhow::Result;
33use clap::Args;
34use serde_json::{Value, json};
35
36use crate::cli::CliOutput;
37use crate::storage as db;
38
39/// CLI args for `ai-memory reflect`. Mirrors the MCP `memory_reflect`
40/// `input_schema` shape.
41#[derive(Args, Debug, Clone)]
42pub struct ReflectArgs {
43    /// One or more source memory ids (comma-separated). Required.
44    #[arg(long = "source-ids", value_name = "CSV", value_delimiter = ',')]
45    pub source_ids: Vec<String>,
46
47    /// Reflection title.
48    #[arg(long, value_name = "TEXT")]
49    pub title: String,
50
51    /// Reflection body.
52    #[arg(long, value_name = "TEXT")]
53    pub content: String,
54
55    /// Tier: short / mid / long.
56    #[arg(long, value_name = "TIER")]
57    pub tier: Option<String>,
58
59    /// Namespace. Defaults to the source memories' namespace.
60    #[arg(long, value_name = "NS")]
61    pub namespace: Option<String>,
62
63    /// Priority 1..=10. Default 5.
64    #[arg(long, value_name = "N")]
65    pub priority: Option<i64>,
66
67    /// Confidence 0.0..=1.0. Default 1.0.
68    #[arg(long, value_name = "F32")]
69    pub confidence: Option<f64>,
70
71    /// Optional tags (comma-separated).
72    #[arg(long, value_name = "CSV", value_delimiter = ',')]
73    pub tags: Vec<String>,
74
75    /// Caller-asserted depth (#1325 — substrate refuses if mismatched).
76    #[arg(long, value_name = "N")]
77    pub depth: Option<i64>,
78
79    /// Caller agent_id override (rare).
80    #[arg(long = "agent-id", value_name = "AGENT_ID")]
81    pub agent_id: Option<String>,
82
83    /// Emit the raw JSON envelope (the same shape MCP / HTTP return)
84    /// instead of a human-readable summary.
85    #[arg(long)]
86    pub json: bool,
87}
88
89/// `ai-memory reflect` dispatch entry.
90///
91/// # Errors
92///
93/// - The DB at `db_path` cannot be opened.
94/// - The substrate refuses the reflection (depth cap, governance veto,
95///   validation, etc.).
96/// - `serde_json::to_string` cannot serialise the envelope.
97pub fn cmd_reflect(
98    db_path: &std::path::Path,
99    args: &ReflectArgs,
100    out: &mut CliOutput<'_>,
101) -> Result<()> {
102    if args.source_ids.is_empty() {
103        anyhow::bail!("reflect: --source-ids is required (comma-separated list)");
104    }
105    let conn = db::open(db_path)?;
106
107    let mut params = json!({
108        (field_names::SOURCE_IDS): args.source_ids,
109        "title": args.title,
110        "content": args.content,
111    });
112    if let Some(t) = &args.tier {
113        params["tier"] = json!(t);
114    }
115    if let Some(ns) = &args.namespace {
116        params["namespace"] = json!(ns);
117    }
118    if let Some(p) = args.priority {
119        params["priority"] = json!(p);
120    }
121    if let Some(c) = args.confidence {
122        params[field_names::CONFIDENCE] = json!(c);
123    }
124    if !args.tags.is_empty() {
125        params["tags"] = json!(args.tags);
126    }
127    if let Some(d) = args.depth {
128        params["depth"] = json!(d);
129    }
130    if let Some(a) = &args.agent_id {
131        params["agent_id"] = json!(a);
132    }
133
134    // CLI is a substrate-internal caller. Match the existing CLI
135    // convention (Skill / Persona / Calibrate): no embedder, no
136    // vector index, no signing keypair, no clientInfo.name. Operators
137    // who need any of those go through the MCP / HTTP daemon.
138    let envelope = crate::mcp::handle_reflect(&conn, db_path, &params, None, None, None, None)
139        .map_err(|e| anyhow::anyhow!("reflect: {e}"))?;
140
141    if args.json {
142        writeln!(out.stdout, "{}", serde_json::to_string(&envelope)?)?;
143        return Ok(());
144    }
145
146    let id = envelope.get("id").and_then(Value::as_str).unwrap_or("?");
147    let depth = envelope
148        .get(field_names::REFLECTION_DEPTH)
149        .and_then(Value::as_i64)
150        .unwrap_or(0);
151    writeln!(out.stdout, "reflect: id={id}  depth={depth}")?;
152    Ok(())
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::cli::test_utils::{TestEnv, seed_memory};
159
160    #[test]
161    fn reflect_cli_missing_source_ids_returns_err() {
162        let mut env = TestEnv::fresh();
163        let db = env.db_path.clone();
164        let args = ReflectArgs {
165            source_ids: vec![],
166            title: "t".into(),
167            content: "c".into(),
168            tier: None,
169            namespace: None,
170            priority: None,
171            confidence: None,
172            tags: vec![],
173            depth: None,
174            agent_id: None,
175            json: true,
176        };
177        let mut out = env.output();
178        let err = cmd_reflect(&db, &args, &mut out).expect_err("must fail");
179        assert!(err.to_string().contains("source-ids"), "got: {err}");
180    }
181
182    #[test]
183    fn reflect_cli_happy_path_writes_envelope() {
184        let mut env = TestEnv::fresh();
185        let db = env.db_path.clone();
186        let s = seed_memory(&db, "rns", "reflect-src", "source content");
187        let args = ReflectArgs {
188            source_ids: vec![s],
189            title: "synthesis".into(),
190            content: "reflection body".into(),
191            tier: Some("mid".into()),
192            namespace: None,
193            priority: None,
194            confidence: None,
195            tags: vec![],
196            depth: None,
197            agent_id: None,
198            json: true,
199        };
200        {
201            let mut out = env.output();
202            cmd_reflect(&db, &args, &mut out).expect("reflect ok");
203        }
204        let stdout = env.stdout_str();
205        let envelope: Value = serde_json::from_str(stdout.trim()).expect("parse envelope");
206        assert!(envelope.get("id").and_then(Value::as_str).is_some());
207    }
208
209    #[test]
210    fn reflect_cli_text_output_with_all_params() {
211        let mut env = TestEnv::fresh();
212        let db = env.db_path.clone();
213        let s = seed_memory(&db, "rns", "reflect-src2", "source content");
214        let args = ReflectArgs {
215            source_ids: vec![s],
216            title: "synthesis-text".into(),
217            content: "reflection body".into(),
218            tier: Some("mid".into()),
219            namespace: Some("rns".into()),
220            priority: Some(7),
221            confidence: Some(0.8),
222            tags: vec!["t1".into(), "t2".into()],
223            depth: Some(1),
224            agent_id: Some("ai:reflector".into()),
225            json: false,
226        };
227        {
228            let mut out = env.output();
229            cmd_reflect(&db, &args, &mut out).expect("reflect ok");
230        }
231        let stdout = env.stdout_str();
232        assert!(stdout.contains("reflect: id="), "got: {stdout}");
233        assert!(stdout.contains("depth="), "got: {stdout}");
234    }
235}