remem-ai 0.6.11

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use super::{MarkdownMemoryDocument, MarkdownMemoryMetadata, EXPORT_VERSION, META_END, META_START};
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};

pub(super) fn render_markdown_memory(doc: &MarkdownMemoryDocument) -> String {
    let metadata = serde_json::to_string_pretty(&doc.metadata)
        .expect("markdown export metadata should serialize");
    format!(
        "{META_START}\n{metadata}\n{META_END}\n\n# {}\n\n{}",
        heading_title(&doc.metadata.title),
        doc.content
    )
}

pub(super) fn parse_markdown_memory(raw: &str) -> Result<MarkdownMemoryDocument> {
    let body = raw
        .strip_prefix(META_START)
        .ok_or_else(|| anyhow!("missing remem markdown metadata start marker"))?;
    let end_marker = format!("\n{META_END}");
    let end = body
        .find(&end_marker)
        .ok_or_else(|| anyhow!("missing remem markdown metadata end marker"))?;
    let mut metadata: MarkdownMemoryMetadata =
        serde_json::from_str(body[..end].trim()).context("parse remem markdown metadata")?;
    let content_start = end + end_marker.len();
    let (heading_title, content) = strip_visible_heading(&body[content_start..]);
    if let Some(title) = heading_title {
        metadata.title = title;
    }
    Ok(MarkdownMemoryDocument { metadata, content })
}

pub(super) fn validate_markdown_metadata(doc: &MarkdownMemoryDocument) -> Result<()> {
    if doc.metadata.remem_export_version != EXPORT_VERSION {
        anyhow::bail!(
            "unsupported remem markdown export version {}",
            doc.metadata.remem_export_version
        );
    }
    if crate::memory::MemoryType::parse(&doc.metadata.memory_type).is_none() {
        anyhow::bail!("unsupported memory_type {}", doc.metadata.memory_type);
    }
    if doc.metadata.project.trim().is_empty() {
        anyhow::bail!("markdown memory project must not be empty");
    }
    if doc.metadata.title.trim().is_empty() {
        anyhow::bail!("markdown memory title must not be empty");
    }
    if doc.content.trim().is_empty() {
        anyhow::bail!("markdown memory content must not be empty");
    }
    if !matches!(
        doc.metadata.status.as_str(),
        "active" | "stale" | "archived"
    ) {
        anyhow::bail!("unsupported markdown memory status {}", doc.metadata.status);
    }
    if !matches!(doc.metadata.scope.as_str(), "project" | "global") {
        anyhow::bail!("unsupported markdown memory scope {}", doc.metadata.scope);
    }
    let reference_time = doc
        .metadata
        .reference_time_epoch
        .or(Some(doc.metadata.created_at_epoch));
    validate_reference_time(
        doc.metadata.source_id,
        &doc.metadata.title,
        &doc.content,
        reference_time,
    )
}

pub(super) fn markdown_file_name(doc: &MarkdownMemoryDocument) -> String {
    let source_id = doc.metadata.source_id.unwrap_or_default();
    let label = doc
        .metadata
        .topic_key
        .as_deref()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or(&doc.metadata.title);
    format!(
        "{source_id:06}-{}-{}.md",
        slug_component(&doc.metadata.memory_type),
        slug_component(label)
    )
}

pub(super) fn markdown_files(source: &Path) -> Result<Vec<PathBuf>> {
    if source.is_file() {
        let is_markdown = source
            .extension()
            .and_then(|ext| ext.to_str())
            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
        return if is_markdown {
            Ok(vec![source.to_path_buf()])
        } else {
            Ok(Vec::new())
        };
    }
    if !source.exists() {
        anyhow::bail!("markdown source not found at {}", source.display());
    }
    let mut files = Vec::new();
    collect_markdown_files(source, &mut files)?;
    files.sort();
    Ok(files)
}

pub(super) fn normalized_topic_key(value: Option<&str>) -> Option<String> {
    value
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
}

pub(super) fn synthesized_markdown_topic_key(path: &Path, title: &str) -> String {
    let stem = path
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or(title);
    format!("markdown-{}", slug_component(stem))
}

fn strip_visible_heading(raw: &str) -> (Option<String>, String) {
    let content = trim_heading_breaks(raw);
    let Some(after_hash) = content.strip_prefix("# ") else {
        return (None, content.to_string());
    };
    let Some(line_end) = after_hash.find('\n') else {
        let heading = after_hash.trim_end_matches('\r');
        return (Some(unescape_heading_title(heading)), String::new());
    };
    let heading = after_hash[..line_end].trim_end_matches('\r');
    let content = trim_heading_breaks(&after_hash[line_end + 1..]);
    (Some(unescape_heading_title(heading)), content.to_string())
}

fn trim_heading_breaks(value: &str) -> &str {
    value.trim_start_matches(['\r', '\n'])
}

fn heading_title(title: &str) -> String {
    title
        .lines()
        .collect::<Vec<_>>()
        .join(" ")
        .replace('#', "\\#")
}

fn unescape_heading_title(title: &str) -> String {
    title.replace("\\#", "#")
}

fn slug_component(value: &str) -> String {
    let mut slug = String::new();
    let mut last_dash = false;
    for ch in value.chars() {
        if ch.is_ascii_alphanumeric() {
            slug.push(ch.to_ascii_lowercase());
            last_dash = false;
        } else if !last_dash {
            slug.push('-');
            last_dash = true;
        }
    }
    let slug = slug.trim_matches('-').to_string();
    if slug.is_empty() {
        "memory".to_string()
    } else {
        slug.chars().take(80).collect()
    }
}

fn collect_markdown_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
    for entry in fs::read_dir(dir).with_context(|| format!("read {}", dir.display()))? {
        let entry = entry?;
        let path = entry.path();
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            collect_markdown_files(&path, files)?;
        } else if file_type.is_file()
            && path
                .extension()
                .and_then(|ext| ext.to_str())
                .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
        {
            files.push(path);
        }
    }
    Ok(())
}

fn validate_reference_time(
    source_id: Option<i64>,
    title: &str,
    content: &str,
    reference_time_epoch: Option<i64>,
) -> Result<()> {
    let has_relative_time = crate::memory::reference_time::contains_relative_time_reference(title)
        || crate::memory::reference_time::contains_relative_time_reference(content);
    if has_relative_time && reference_time_epoch.is_none_or(|epoch| epoch <= 0) {
        let source = source_id
            .map(|id| format!("source memory id={id}"))
            .unwrap_or_else(|| "markdown memory".to_string());
        anyhow::bail!("{source}: relative dates require a positive reference_time_epoch");
    }
    Ok(())
}