mps-rs 1.8.2

MPS — plain-text personal productivity CLI (Rust)
Documentation
use crate::parser;
use crate::store::Store;
use anyhow::Context as _;
use chrono::NaiveDate;

/// Build the LLM system prompt from journal data between `since` and `today` (inclusive).
///
/// Returns (prompt_text, element_count). Elements are grouped under date headers, oldest-first.
/// Unknown elements are skipped; MpsGroup elements are also skipped (internal metadata).
pub fn build_system_prompt(
    store: &Store,
    since: NaiveDate,
    today: NaiveDate,
) -> anyhow::Result<(String, usize)> {
    let files = store
        .files_since(since)
        .context("failed to list journal files")?;

    let mut body = String::new();
    let mut count = 0usize;

    // Collect dates from the files, sorted
    let mut dated_files: Vec<(NaiveDate, std::path::PathBuf)> = files
        .into_iter()
        .filter_map(|path| {
            let name = path.file_name()?.to_str()?.to_string();
            // file names: YYYYMMDD[.epoch].mps
            let date_str: String = name.chars().take(8).collect();
            let date = NaiveDate::parse_from_str(&date_str, "%Y%m%d").ok()?;
            if date <= today {
                Some((date, path))
            } else {
                None
            }
        })
        .collect();
    dated_files.sort_by_key(|(d, _)| *d);

    // Group files by date so multiple files on the same day share one header.
    let mut i = 0;
    while i < dated_files.len() {
        let date = dated_files[i].0;
        let mut day_elements: Vec<String> = Vec::new();

        // Collect all files for this date.
        while i < dated_files.len() && dated_files[i].0 == date {
            let path = &dated_files[i].1;
            if let Ok(elements) = parser::parse_file(path) {
                for el in elements.values() {
                    if !el.is_unknown() && !el.is_mps_group() {
                        day_elements.push(format_element(el));
                    }
                }
            }
            i += 1;
        }

        if !day_elements.is_empty() {
            body.push_str(&format!("\n--- {} ---\n", date.format("%Y-%m-%d")));
            for line in &day_elements {
                body.push_str(line);
                body.push('\n');
            }
            count += day_elements.len();
        }
    }

    let prompt = format!(
        "You are a helpful assistant for mps, a personal productivity journal.\n\
         Today is {}. Answer questions using the journal data below.\n\
         Keep responses concise and practical.\n\
         \n\
         === MPS DATA ({} to {}) ===\
         {}",
        today.format("%Y-%m-%d"),
        since.format("%Y-%m-%d"),
        today.format("%Y-%m-%d"),
        if body.is_empty() {
            "\n(no entries in this date range)".to_string()
        } else {
            body
        }
    );

    Ok((prompt, count))
}

fn format_element(element: &crate::elements::Element) -> String {
    let kind = element.kind().to_string();
    let body = element.body_str();
    let tags = element.tags();
    let attrs = element.typed_attrs();

    let tag_str = if tags.is_empty() {
        String::new()
    } else {
        format!(
            "  {}",
            tags.iter()
                .map(|t| format!("#{}", t))
                .collect::<Vec<_>>()
                .join(" ")
        )
    };

    let attr_str = if attrs.is_empty() {
        String::new()
    } else {
        let parts: Vec<String> = attrs
            .iter()
            .map(|(k, v)| format!("[{}:{}]", k, v))
            .collect();
        format!("  {}", parts.join(" "))
    };

    format!("[{}] {}{}{}", kind, body, tag_str, attr_str)
}