use serde_json::{Value, json};
use crate::cli::commands::export_reflections::{self, ExportFormat};
use crate::db;
use crate::models::MemoryKind;
pub fn handle_export_reflection(
conn: &rusqlite::Connection,
params: &Value,
) -> Result<Value, String> {
let memory_id = params["memory_id"]
.as_str()
.ok_or(crate::errors::msg::MEMORY_ID_REQUIRED)?;
if memory_id.is_empty() {
return Err(crate::errors::msg::MEMORY_ID_EMPTY.to_string());
}
let format_str = params["format"].as_str().unwrap_or("md");
let format = parse_format_for_mcp(format_str)?;
let mem = db::get(conn, memory_id)
.map_err(|e| format!("memory_export_reflection substrate error: {e}"))?
.ok_or_else(|| crate::errors::msg::memory_not_found(memory_id))?;
if !matches!(mem.memory_kind, MemoryKind::Reflection) {
return Err(format!("memory is not a reflection: {memory_id}"));
}
let edges = collect_outbound_reflects_on(conn, memory_id)
.map_err(|e| format!("reading reflects_on links: {e}"))?;
let attest_level = export_reflections::summarise_attest_level(&edges);
let content = export_reflections::render_payload(&mem, &edges, attest_level, format);
let suggested = suggested_filename(&mem.namespace, &mem.id, format);
Ok(json!({
"content": content,
"suggested_filename": suggested,
}))
}
fn parse_format_for_mcp(spec: &str) -> Result<ExportFormat, String> {
match spec.to_lowercase().as_str() {
"md" | "markdown" => Ok(ExportFormat::Markdown),
"json" => Ok(ExportFormat::Json),
other => Err(format!(
"unsupported export format '{other}' (expected 'md' or 'json')"
)),
}
}
fn collect_outbound_reflects_on(
conn: &rusqlite::Connection,
memory_id: &str,
) -> Result<Vec<export_reflections::ReflectsOnEdge>, anyhow::Error> {
let mut stmt = conn.prepare(
"SELECT target_id, COALESCE(attest_level, 'unsigned'), created_at \
FROM memory_links \
WHERE source_id = ?1 AND relation = 'reflects_on' \
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(rusqlite::params![memory_id], |row| {
Ok(export_reflections::ReflectsOnEdge {
target_id: row.get(0)?,
attest_level: row.get(1)?,
created_at: row.get(2)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
fn suggested_filename(namespace: &str, id: &str, format: ExportFormat) -> String {
let ns_clean = namespace.trim_matches('/');
if ns_clean.is_empty() {
format!("{id}.{ext}", ext = format.extension())
} else {
format!("{ns_clean}/{id}.{ext}", ext = format.extension())
}
}
use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct ExportReflectionRequest {
pub memory_id: String,
#[serde(default)]
pub format: Option<String>,
}
#[allow(dead_code)]
pub struct ExportReflectionTool;
impl McpTool for ExportReflectionTool {
fn name() -> &'static str {
crate::mcp::registry::tool_names::MEMORY_EXPORT_REFLECTION
}
fn description() -> &'static str {
"Render a single reflection memory as markdown or JSON (no filesystem write)."
}
fn docs() -> &'static str {
"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."
}
fn input_schema() -> Value {
crate::mcp::registry::input_schema_for::<ExportReflectionRequest>()
}
fn family() -> &'static str {
crate::profile::Family::Power.name()
}
}
#[cfg(test)]
mod d1_5_986_tests {
use super::*;
use crate::mcp::parity_test_helpers::{
assert_descriptions_match, assert_property_set_parity, derived_props_for,
};
#[test]
fn export_reflection_parity_986() {
let derived = derived_props_for::<ExportReflectionRequest>();
assert_property_set_parity("memory_export_reflection", &derived);
assert_descriptions_match("memory_export_reflection", &derived);
}
#[test]
fn export_reflection_tool_metadata_986() {
assert_eq!(ExportReflectionTool::name(), "memory_export_reflection");
assert_eq!(ExportReflectionTool::family(), "power");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{Memory, Tier};
use chrono::Utc;
use tempfile::TempDir;
fn fresh_db() -> (rusqlite::Connection, TempDir) {
let dir = TempDir::new().unwrap();
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).unwrap();
(conn, dir)
}
fn make_reflection(ns: &str, depth: i32, agent_id: &str) -> Memory {
let now = Utc::now().to_rfc3339();
Memory {
id: uuid::Uuid::new_v4().to_string(),
tier: Tier::Mid,
namespace: ns.to_string(),
title: "rfl".into(),
content: "body".into(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".into(),
access_count: 0,
created_at: now.clone(),
updated_at: now,
last_accessed_at: None,
expires_at: None,
metadata: serde_json::json!({"agent_id": agent_id}),
reflection_depth: depth,
memory_kind: MemoryKind::Reflection,
entity_id: None,
persona_version: None,
citations: Vec::new(),
source_uri: None,
source_span: None,
confidence_source: crate::models::ConfidenceSource::CallerProvided,
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
}
}
#[test]
fn missing_memory_id_errors() {
let (conn, _g) = fresh_db();
let err = handle_export_reflection(&conn, &json!({})).unwrap_err();
assert!(err.contains("memory_id"));
}
#[test]
fn empty_memory_id_errors() {
let (conn, _g) = fresh_db();
let err = handle_export_reflection(&conn, &json!({"memory_id": ""})).unwrap_err();
assert!(err.contains("empty"));
}
#[test]
fn unknown_id_errors() {
let (conn, _g) = fresh_db();
let err = handle_export_reflection(
&conn,
&json!({"memory_id": "11111111-2222-3333-4444-555555555555"}),
)
.unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn observation_kind_errors() {
let (conn, _g) = fresh_db();
let mut obs = make_reflection("ns", 0, "ai:test");
obs.memory_kind = MemoryKind::Observation;
obs.reflection_depth = 0;
let id = db::insert(&conn, &obs).unwrap();
let err = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap_err();
assert!(err.contains("not a reflection"));
}
#[test]
fn unsupported_format_errors() {
let (conn, _g) = fresh_db();
let rfl = make_reflection("ns", 1, "ai:test");
let id = db::insert(&conn, &rfl).unwrap();
let err = handle_export_reflection(&conn, &json!({"memory_id": id, "format": "yaml"}))
.unwrap_err();
assert!(err.contains("unsupported export format"));
}
#[test]
fn happy_path_md_returns_content_and_filename() {
let (conn, _g) = fresh_db();
let rfl = make_reflection("team/alpha", 1, "ai:bot");
let id = db::insert(&conn, &rfl).unwrap();
let out = handle_export_reflection(&conn, &json!({"memory_id": id})).unwrap();
let content = out["content"].as_str().unwrap();
assert!(content.starts_with("---\n"));
assert!(content.contains(&format!("memory_id: {id}\n")));
let fname = out["suggested_filename"].as_str().unwrap();
assert_eq!(fname, format!("team/alpha/{id}.md"));
}
#[test]
fn happy_path_json_returns_parsable_envelope() {
let (conn, _g) = fresh_db();
let rfl = make_reflection("ns", 2, "ai:bot");
let id = db::insert(&conn, &rfl).unwrap();
let out =
handle_export_reflection(&conn, &json!({"memory_id": id, "format": "json"})).unwrap();
let content = out["content"].as_str().unwrap();
let parsed: serde_json::Value = serde_json::from_str(content).unwrap();
assert_eq!(parsed["memory_id"].as_str().unwrap(), id);
assert_eq!(parsed["namespace"].as_str().unwrap(), "ns");
assert_eq!(parsed["reflection_depth"].as_i64().unwrap(), 2);
let fname = out["suggested_filename"].as_str().unwrap();
assert!(fname.ends_with(".json"));
}
#[test]
fn suggested_filename_strips_slashes() {
assert_eq!(
suggested_filename("/team/alpha/", "abc", ExportFormat::Markdown),
"team/alpha/abc.md"
);
assert_eq!(
suggested_filename("", "abc", ExportFormat::Json),
"abc.json"
);
}
}