use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
use clap::Args;
use serde::Serialize;
use crate::cli::CliOutput;
use crate::db;
use crate::models::{Memory, MemoryKind};
pub(crate) const REFLECTIONS_SUBDIR: &str = "reflections";
#[derive(Args, Debug, Clone)]
pub struct ExportReflectionsArgs {
#[arg(long, value_name = "NS")]
pub namespace: Option<String>,
#[arg(long, value_name = "PATH")]
pub out_dir: Option<PathBuf>,
#[arg(long, default_value = "md", value_name = "FMT")]
pub format: String,
#[arg(long, value_name = "RFC3339")]
pub since: Option<String>,
#[arg(long, default_value_t = false)]
pub quiet: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExportSummary {
pub written: usize,
pub skipped: usize,
}
#[derive(Debug, Serialize)]
struct JsonEnvelope<'a> {
memory_id: &'a str,
namespace: &'a str,
title: &'a str,
reflection_depth: i32,
attest_level: &'a str,
created_at: &'a str,
agent_id: &'a str,
reflects_on: Vec<String>,
content: &'a str,
}
pub fn run(db_path: &Path, args: &ExportReflectionsArgs, out: &mut CliOutput<'_>) -> Result<i32> {
let format = parse_format(&args.format)?;
let out_dir = resolve_out_dir(args.out_dir.as_deref())?;
fs::create_dir_all(&out_dir)
.with_context(|| format!("creating out-dir {}", out_dir.display()))?;
let conn = db::open(db_path)?;
let mut summary = ExportSummary::default();
let reflections = collect_reflections(&conn, args.namespace.as_deref(), args.since.as_deref())?;
for mem in &reflections {
let edges = collect_outbound_reflects_on(&conn, &mem.id)?;
let attest_level = summarise_attest_level(&edges);
let payload = render_payload(mem, &edges, attest_level, format);
let ns_dir = out_dir.join(sanitise_namespace_for_path(&mem.namespace));
fs::create_dir_all(&ns_dir)
.with_context(|| format!("creating namespace dir {}", ns_dir.display()))?;
let filename = format!("{}.{}", mem.id, format.extension());
let path = ns_dir.join(&filename);
fs::write(&path, payload).with_context(|| crate::errors::msg::writing(path.display()))?;
summary.written += 1;
if !args.quiet {
writeln!(out.stdout, "wrote {}", path.display())?;
}
}
writeln!(
out.stdout,
"exported {} reflection(s) to {}",
summary.written,
out_dir.display()
)?;
let _ = summary.skipped; Ok(0)
}
#[derive(Debug, Clone)]
pub(crate) struct ReflectsOnEdge {
pub target_id: String,
pub attest_level: String,
pub created_at: String,
}
pub(crate) fn parse_format(spec: &str) -> Result<ExportFormat> {
match spec.to_lowercase().as_str() {
"md" | "markdown" => Ok(ExportFormat::Markdown),
"json" => Ok(ExportFormat::Json),
other => anyhow::bail!("unsupported export format '{other}' (expected 'md' or 'json')"),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Markdown,
Json,
}
impl ExportFormat {
pub(crate) fn extension(self) -> &'static str {
match self {
Self::Markdown => "md",
Self::Json => "json",
}
}
}
pub(crate) fn resolve_out_dir(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
return Ok(p.to_path_buf());
}
if let Some(home) = std::env::var_os("HOME") {
return Ok(PathBuf::from(home)
.join(crate::AI_MEMORY_HOME_DIR_NAME)
.join(REFLECTIONS_SUBDIR));
}
Ok(PathBuf::from(crate::AI_MEMORY_HOME_DIR_NAME).join(REFLECTIONS_SUBDIR))
}
pub(crate) fn sanitise_namespace_for_path(ns: &str) -> PathBuf {
let mut buf = PathBuf::new();
for component in ns.split('/') {
let cleaned: String = component
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
if !cleaned.is_empty() {
buf.push(cleaned);
}
}
if buf.as_os_str().is_empty() {
buf.push("_unnamed");
}
buf
}
fn collect_reflections(
conn: &rusqlite::Connection,
namespace: Option<&str>,
since: Option<&str>,
) -> Result<Vec<Memory>> {
let now = Utc::now().to_rfc3339();
let _ = now; let rows = db::list(
conn,
namespace,
None,
i32::MAX as usize,
0,
None,
since,
None,
None,
None,
)?;
Ok(rows
.into_iter()
.filter(|m| matches!(m.memory_kind, MemoryKind::Reflection))
.collect())
}
fn collect_outbound_reflects_on(
conn: &rusqlite::Connection,
memory_id: &str,
) -> Result<Vec<ReflectsOnEdge>> {
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(ReflectsOnEdge {
target_id: row.get(0)?,
attest_level: row.get(1)?,
created_at: row.get(2)?,
})
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
pub(crate) fn summarise_attest_level(edges: &[ReflectsOnEdge]) -> &'static str {
let mut best = 0u8;
for e in edges {
let rank: u8 = match e.attest_level.as_str() {
"signed" => 3,
s if s == crate::models::AttestLevel::PeerAttested.as_str() => 2,
s if s == crate::models::AttestLevel::SelfSigned.as_str() => 1,
_ => 0,
};
if rank > best {
best = rank;
}
}
match best {
3 => "signed",
2 => crate::models::AttestLevel::PeerAttested.as_str(),
1 => crate::models::AttestLevel::SelfSigned.as_str(),
_ => crate::models::AttestLevel::Unsigned.as_str(),
}
}
pub(crate) fn agent_id_of(mem: &Memory) -> &str {
mem.metadata
.get("agent_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
}
pub(crate) fn render_payload(
mem: &Memory,
edges: &[ReflectsOnEdge],
attest_level: &str,
format: ExportFormat,
) -> String {
match format {
ExportFormat::Markdown => render_markdown(mem, edges, attest_level),
ExportFormat::Json => render_json(mem, edges, attest_level),
}
}
fn render_markdown(mem: &Memory, edges: &[ReflectsOnEdge], attest_level: &str) -> String {
let agent_id = agent_id_of(mem);
let mut out = String::with_capacity(256 + mem.content.len());
out.push_str("---\n");
out.push_str(&format!("memory_id: {}\n", mem.id));
out.push_str(&format!("namespace: {}\n", yaml_scalar(&mem.namespace)));
out.push_str(&format!("title: {}\n", yaml_scalar(&mem.title)));
out.push_str(&format!("reflection_depth: {}\n", mem.reflection_depth));
out.push_str(&format!("attest_level: {attest_level}\n"));
out.push_str(&format!("created_at: {}\n", mem.created_at));
out.push_str(&format!("agent_id: {}\n", yaml_scalar(agent_id)));
out.push_str("reflects_on:\n");
if edges.is_empty() {
out.push_str(" []\n");
} else {
for e in edges {
out.push_str(&format!(
" - target_id: {}\n attest_level: {}\n created_at: {}\n",
e.target_id, e.attest_level, e.created_at,
));
}
}
out.push_str("---\n\n");
out.push_str(&mem.content);
if !mem.content.ends_with('\n') {
out.push('\n');
}
out
}
fn render_json(mem: &Memory, edges: &[ReflectsOnEdge], attest_level: &str) -> String {
let agent_id = agent_id_of(mem);
let env = JsonEnvelope {
memory_id: &mem.id,
namespace: &mem.namespace,
title: &mem.title,
reflection_depth: mem.reflection_depth,
attest_level,
created_at: &mem.created_at,
agent_id,
reflects_on: edges.iter().map(|e| e.target_id.clone()).collect(),
content: &mem.content,
};
serde_json::to_string_pretty(&env).unwrap_or_else(|e| format!("{{\"error\": \"{e}\"}}"))
}
fn yaml_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.starts_with(['-', '?', '*', '&', '!', '|', '>', '\'', '"', '%', '@', '`'])
|| s.contains(':')
|| s.contains('#')
|| s.contains('\n');
if needs_quote {
let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Tier;
use chrono::Utc;
use tempfile::TempDir;
fn fresh_db() -> (rusqlite::Connection, TempDir) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("ai-memory.db");
let conn = db::open(&path).expect("db::open");
(conn, dir)
}
fn make_reflection(ns: &str, depth: i32, title: &str, body: &str, 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: title.to_string(),
content: body.to_string(),
tags: vec![],
priority: 5,
confidence: 1.0,
source: "test".to_string(),
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 parse_format_accepts_md_and_json() {
assert_eq!(parse_format("md").unwrap(), ExportFormat::Markdown);
assert_eq!(parse_format("markdown").unwrap(), ExportFormat::Markdown);
assert_eq!(parse_format("MD").unwrap(), ExportFormat::Markdown);
assert_eq!(parse_format("json").unwrap(), ExportFormat::Json);
assert!(parse_format("yaml").is_err());
}
#[test]
fn sanitise_namespace_handles_slashes_and_weird_chars() {
let p = sanitise_namespace_for_path("team/alpha");
assert_eq!(p, PathBuf::from("team").join("alpha"));
let p2 = sanitise_namespace_for_path("evil:ns?with*bits");
assert_eq!(p2, PathBuf::from("evil_ns_with_bits"));
let p3 = sanitise_namespace_for_path("");
assert_eq!(p3, PathBuf::from("_unnamed"));
}
#[test]
fn summarise_attest_level_promotes_to_highest() {
let mk = |s: &str| ReflectsOnEdge {
target_id: "x".into(),
attest_level: s.into(),
created_at: "2026-01-01".into(),
};
assert_eq!(summarise_attest_level(&[]), "unsigned");
assert_eq!(
summarise_attest_level(&[mk("unsigned"), mk("unsigned")]),
"unsigned"
);
assert_eq!(
summarise_attest_level(&[mk("unsigned"), mk("self_signed")]),
"self_signed"
);
assert_eq!(
summarise_attest_level(&[mk("self_signed"), mk("peer_attested")]),
"peer_attested"
);
assert_eq!(
summarise_attest_level(&[mk("peer_attested"), mk("signed")]),
"signed"
);
}
#[test]
fn render_markdown_carries_frontmatter_and_edges() {
let mem = make_reflection(
"team/alpha",
2,
"lesson learned",
"Body line.\n",
"agent-without-colon",
);
let edges = vec![
ReflectsOnEdge {
target_id: "src-1".into(),
attest_level: "unsigned".into(),
created_at: "2026-01-01T00:00:00Z".into(),
},
ReflectsOnEdge {
target_id: "src-2".into(),
attest_level: "signed".into(),
created_at: "2026-01-02T00:00:00Z".into(),
},
];
let s = render_markdown(&mem, &edges, "signed");
assert!(s.starts_with("---\n"));
assert!(s.contains(&format!("memory_id: {}\n", mem.id)));
assert!(s.contains("namespace: team/alpha\n"));
assert!(s.contains("reflection_depth: 2\n"));
assert!(s.contains("attest_level: signed\n"));
assert!(s.contains("agent_id: agent-without-colon\n"));
assert!(s.contains(" - target_id: src-1\n"));
assert!(s.contains(" attest_level: signed\n"));
assert!(s.ends_with("Body line.\n"));
}
#[test]
fn render_markdown_quotes_agent_id_with_colon() {
let mem = make_reflection("ns", 1, "t", "body", "ai:bot");
let s = render_markdown(&mem, &[], "unsigned");
assert!(s.contains("agent_id: \"ai:bot\"\n"));
}
#[test]
fn render_markdown_quotes_yaml_unsafe_strings() {
let mut mem = make_reflection("global", 1, "weird: title", "body", "");
mem.namespace = "weird:ns".into();
let s = render_markdown(&mem, &[], "unsigned");
assert!(s.contains("title: \"weird: title\"\n"));
assert!(s.contains("namespace: \"weird:ns\"\n"));
assert!(s.contains("agent_id: \"\"\n"));
assert!(s.contains("reflects_on:\n []\n"));
}
#[test]
fn render_json_emits_pretty_envelope() {
let mem = make_reflection("ns", 1, "t", "body content\n", "ai:bot");
let edges = vec![ReflectsOnEdge {
target_id: "src".into(),
attest_level: "self_signed".into(),
created_at: "2026-01-01".into(),
}];
let s = render_json(&mem, &edges, "self_signed");
let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(parsed["memory_id"].as_str().unwrap(), mem.id);
assert_eq!(parsed["namespace"].as_str().unwrap(), "ns");
assert_eq!(parsed["reflection_depth"].as_i64().unwrap(), 1);
assert_eq!(parsed["attest_level"].as_str().unwrap(), "self_signed");
assert_eq!(parsed["agent_id"].as_str().unwrap(), "ai:bot");
assert_eq!(parsed["reflects_on"].as_array().unwrap().len(), 1);
assert_eq!(parsed["reflects_on"][0].as_str().unwrap(), "src");
assert!(parsed["content"].as_str().unwrap().contains("body content"));
}
#[test]
fn resolve_out_dir_explicit_overrides_default() {
let p = resolve_out_dir(Some(Path::new("/tmp/some-path"))).unwrap();
assert_eq!(p, PathBuf::from("/tmp/some-path"));
}
#[test]
fn collect_reflections_filters_observations() {
let (conn, _g) = fresh_db();
let r = make_reflection("ns-r", 1, "rfl", "rfl body", "ai:a");
db::insert(&conn, &r).unwrap();
let mut obs = make_reflection("ns-r", 0, "obs", "obs body", "ai:a");
obs.memory_kind = MemoryKind::Observation;
obs.reflection_depth = 0;
db::insert(&conn, &obs).unwrap();
let collected = collect_reflections(&conn, Some("ns-r"), None).unwrap();
assert_eq!(collected.len(), 1);
assert!(matches!(collected[0].memory_kind, MemoryKind::Reflection));
}
#[test]
fn agent_id_of_returns_empty_when_absent() {
let mut mem = make_reflection("n", 1, "t", "c", "");
mem.metadata = serde_json::json!({});
assert_eq!(agent_id_of(&mem), "");
}
}