1use serde_json::{Value, json};
26
27use crate::cli::commands::export_reflections::{self, ExportFormat};
28use crate::db;
29use crate::models::MemoryKind;
30
31pub fn handle_export_reflection(
48 conn: &rusqlite::Connection,
49 params: &Value,
50) -> Result<Value, String> {
51 let memory_id = params["memory_id"]
52 .as_str()
53 .ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?;
54 if memory_id.is_empty() {
55 return Err(crate::errors::msg::MEMORY_ID_EMPTY.to_string());
56 }
57 let format_str = params["format"].as_str().unwrap_or("md");
58 let format = parse_format_for_mcp(format_str)?;
59
60 let mem = db::get(conn, memory_id)
61 .map_err(|e| format!("memory_export_reflection substrate error: {e}"))?
62 .ok_or_else(|| crate::errors::msg::memory_not_found(memory_id))?;
63 if !matches!(mem.memory_kind, MemoryKind::Reflection) {
64 return Err(format!("memory is not a reflection: {memory_id}"));
65 }
66
67 let edges = collect_outbound_reflects_on(conn, memory_id)
68 .map_err(|e| format!("reading reflects_on links: {e}"))?;
69 let attest_level = export_reflections::summarise_attest_level(&edges);
70 let content = export_reflections::render_payload(&mem, &edges, attest_level, format);
71 let suggested = suggested_filename(&mem.namespace, &mem.id, format);
72 Ok(json!({
73 "content": content,
74 "suggested_filename": suggested,
75 }))
76}
77
78fn parse_format_for_mcp(spec: &str) -> Result<ExportFormat, String> {
82 match spec.to_lowercase().as_str() {
83 "md" | "markdown" => Ok(ExportFormat::Markdown),
84 "json" => Ok(ExportFormat::Json),
85 other => Err(format!(
86 "unsupported export format '{other}' (expected 'md' or 'json')"
87 )),
88 }
89}
90
91fn collect_outbound_reflects_on(
96 conn: &rusqlite::Connection,
97 memory_id: &str,
98) -> Result<Vec<export_reflections::ReflectsOnEdge>, anyhow::Error> {
99 let mut stmt = conn.prepare(
100 "SELECT target_id, COALESCE(attest_level, 'unsigned'), created_at \
101 FROM memory_links \
102 WHERE source_id = ?1 AND relation = 'reflects_on' \
103 ORDER BY created_at ASC",
104 )?;
105 let rows = stmt.query_map(rusqlite::params![memory_id], |row| {
106 Ok(export_reflections::ReflectsOnEdge {
107 target_id: row.get(0)?,
108 attest_level: row.get(1)?,
109 created_at: row.get(2)?,
110 })
111 })?;
112 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
113}
114
115fn suggested_filename(namespace: &str, id: &str, format: ExportFormat) -> String {
119 let ns_clean = namespace.trim_matches('/');
120 if ns_clean.is_empty() {
121 format!("{id}.{ext}", ext = format.extension())
122 } else {
123 format!("{ns_clean}/{id}.{ext}", ext = format.extension())
124 }
125}
126
127use crate::mcp::registry::McpTool;
130use schemars::JsonSchema;
131use serde::Deserialize;
132
133#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
135#[allow(dead_code)]
136pub struct ExportReflectionRequest {
137 pub memory_id: String,
139
140 #[serde(default)]
142 pub format: Option<String>,
143}
144
145#[allow(dead_code)]
147pub struct ExportReflectionTool;
148
149impl McpTool for ExportReflectionTool {
150 fn name() -> &'static str {
151 crate::mcp::registry::tool_names::MEMORY_EXPORT_REFLECTION
152 }
153 fn description() -> &'static str {
154 "Render a single reflection memory as markdown or JSON (no filesystem write)."
155 }
156 fn docs() -> &'static str {
157 "QW-1: render reflection + reflects_on provenance as YAML-frontmatter md (default) or JSON envelope. Returns {content, suggested_filename}. No FS write — harness owns disk I/O."
158 }
159 fn input_schema() -> Value {
160 crate::mcp::registry::input_schema_for::<ExportReflectionRequest>()
161 }
162 fn family() -> &'static str {
163 crate::profile::Family::Power.name()
164 }
165}
166
167#[cfg(test)]
168mod d1_5_986_tests {
169 use super::*;
172 use crate::mcp::parity_test_helpers::{
173 assert_descriptions_match, assert_property_set_parity, derived_props_for,
174 };
175
176 #[test]
177 fn export_reflection_parity_986() {
178 let derived = derived_props_for::<ExportReflectionRequest>();
179 assert_property_set_parity("memory_export_reflection", &derived);
180 assert_descriptions_match("memory_export_reflection", &derived);
181 }
182
183 #[test]
184 fn export_reflection_tool_metadata_986() {
185 assert_eq!(ExportReflectionTool::name(), "memory_export_reflection");
186 assert_eq!(ExportReflectionTool::family(), "power");
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::models::{Memory, Tier};
194 use chrono::Utc;
195 use tempfile::TempDir;
196
197 fn fresh_db() -> (rusqlite::Connection, TempDir) {
198 let dir = TempDir::new().unwrap();
199 let path = dir.path().join("ai-memory.db");
200 let conn = db::open(&path).unwrap();
201 (conn, dir)
202 }
203
204 fn make_reflection(ns: &str, depth: i32, agent_id: &str) -> Memory {
205 let now = Utc::now().to_rfc3339();
206 Memory {
207 id: uuid::Uuid::new_v4().to_string(),
208 tier: Tier::Mid,
209 namespace: ns.to_string(),
210 title: "rfl".into(),
211 content: "body".into(),
212 tags: vec![],
213 priority: 5,
214 confidence: 1.0,
215 source: "test".into(),
216 access_count: 0,
217 created_at: now.clone(),
218 updated_at: now,
219 last_accessed_at: None,
220 expires_at: None,
221 metadata: serde_json::json!({"agent_id": agent_id}),
222 reflection_depth: depth,
223 memory_kind: MemoryKind::Reflection,
224 entity_id: None,
225 persona_version: None,
226 citations: Vec::new(),
227 source_uri: None,
228 source_span: None,
229 confidence_source: crate::models::ConfidenceSource::CallerProvided,
230 confidence_signals: None,
231 confidence_decayed_at: None,
232 version: 1,
233 }
234 }
235
236 #[test]
237 fn missing_memory_id_errors() {
238 let (conn, _g) = fresh_db();
239 let err = handle_export_reflection(&conn, &json!({})).unwrap_err();
240 assert!(err.contains("memory_id"));
241 }
242
243 #[test]
244 fn empty_memory_id_errors() {
245 let (conn, _g) = fresh_db();
246 let err = handle_export_reflection(&conn, &json!({"memory_id": ""})).unwrap_err();
247 assert!(err.contains("empty"));
248 }
249
250 #[test]
251 fn unknown_id_errors() {
252 let (conn, _g) = fresh_db();
253 let err = handle_export_reflection(
254 &conn,
255 &json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
256 )
257 .unwrap_err();
258 assert!(err.contains("not found"));
259 }
260
261 #[test]
262 fn observation_kind_errors() {
263 let (conn, _g) = fresh_db();
264 let mut obs = make_reflection("ns", 0, "ai:test");
265 obs.memory_kind = MemoryKind::Observation;
266 obs.reflection_depth = 0;
267 let id = db::insert(&conn, &obs).unwrap();
268 let err = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap_err();
269 assert!(err.contains("not a reflection"));
270 }
271
272 #[test]
273 fn unsupported_format_errors() {
274 let (conn, _g) = fresh_db();
275 let rfl = make_reflection("ns", 1, "ai:test");
276 let id = db::insert(&conn, &rfl).unwrap();
277 let err = handle_export_reflection(&conn, &json!({"memory_id": id, "format": "yaml"}))
278 .unwrap_err();
279 assert!(err.contains("unsupported export format"));
280 }
281
282 #[test]
283 fn happy_path_md_returns_content_and_filename() {
284 let (conn, _g) = fresh_db();
285 let rfl = make_reflection("team/alpha", 1, "ai:bot");
286 let id = db::insert(&conn, &rfl).unwrap();
287 let out = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap();
288 let content = out["content"].as_str().unwrap();
289 assert!(content.starts_with("---\n"));
290 assert!(content.contains(&format!("memory_id: {id}\n")));
291 let fname = out["suggested_filename"].as_str().unwrap();
292 assert_eq!(fname, format!("team/alpha/{id}.md"));
293 }
294
295 #[test]
296 fn happy_path_json_returns_parsable_envelope() {
297 let (conn, _g) = fresh_db();
298 let rfl = make_reflection("ns", 2, "ai:bot");
299 let id = db::insert(&conn, &rfl).unwrap();
300 let out =
301 handle_export_reflection(&conn, &json!({"memory_id": id, "format": "json"})).unwrap();
302 let content = out["content"].as_str().unwrap();
303 let parsed: serde_json::Value = serde_json::from_str(content).unwrap();
304 assert_eq!(parsed["memory_id"].as_str().unwrap(), id);
305 assert_eq!(parsed["namespace"].as_str().unwrap(), "ns");
306 assert_eq!(parsed["reflection_depth"].as_i64().unwrap(), 2);
307 let fname = out["suggested_filename"].as_str().unwrap();
308 assert!(fname.ends_with(".json"));
309 }
310
311 #[test]
312 fn suggested_filename_strips_slashes() {
313 assert_eq!(
314 suggested_filename("/team/alpha/", "abc", ExportFormat::Markdown),
315 "team/alpha/abc.md"
316 );
317 assert_eq!(
318 suggested_filename("", "abc", ExportFormat::Json),
319 "abc.json"
320 );
321 }
322}