episodic 0.2.3

Reusable Observational Memory core models and pure transforms.
Documentation
use std::collections::BTreeMap;

use crate::xml::{escape_xml_attribute, escape_xml_text};

use super::super::helpers::normalize_whitespace;
use super::super::types::{BUFFERED_OBSERVATIONS_SEPARATOR, OmObserverMessageCandidate};

pub fn build_other_conversation_blocks(
    candidates: &[OmObserverMessageCandidate],
    current_session_id: Option<&str>,
    max_part_chars: usize,
) -> Option<String> {
    if max_part_chars == 0 {
        return None;
    }

    let normalized_current_session_id = current_session_id
        .map(str::trim)
        .filter(|value| !value.is_empty());

    let mut groups = BTreeMap::<String, Vec<&OmObserverMessageCandidate>>::new();
    for candidate in candidates {
        let Some(source_session_id) = candidate
            .source_session_id
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
        else {
            continue;
        };
        if normalized_current_session_id.is_some_and(|current| current == source_session_id) {
            continue;
        }
        groups
            .entry(source_session_id.to_string())
            .or_default()
            .push(candidate);
    }

    if groups.is_empty() {
        return None;
    }

    let mut blocks = Vec::<String>::new();
    for (source_session_id, mut messages) in groups {
        messages.sort_by(|a, b| {
            a.created_at
                .cmp(&b.created_at)
                .then_with(|| a.id.cmp(&b.id))
        });
        let lines = messages
            .into_iter()
            .filter_map(|message| {
                let role = normalize_whitespace(&message.role);
                let text = normalize_whitespace(&message.text);
                if text.is_empty() {
                    return None;
                }
                let bounded = text.chars().take(max_part_chars).collect::<String>();
                if bounded.is_empty() {
                    return None;
                }
                let escaped_text = escape_xml_text(&bounded);
                Some(if role.is_empty() {
                    escaped_text
                } else {
                    format!("[{}] {escaped_text}", escape_xml_text(&role))
                })
            })
            .collect::<Vec<_>>();
        if lines.is_empty() {
            continue;
        }
        let source_session_id = escape_xml_attribute(&source_session_id);
        blocks.push(format!(
            "<other-conversation id=\"{source_session_id}\">\n{}\n</other-conversation>",
            lines.join("\n")
        ));
    }

    if blocks.is_empty() {
        None
    } else {
        Some(blocks.join("\n\n"))
    }
}

pub fn combine_observations_for_buffering(
    active_observations: &str,
    buffered_observations: &str,
) -> Option<String> {
    let active = active_observations.trim();
    let buffered = buffered_observations.trim();
    if active.is_empty() && buffered.is_empty() {
        return None;
    }
    if active.is_empty() {
        return Some(buffered.to_string());
    }
    if buffered.is_empty() {
        return Some(active.to_string());
    }
    Some(format!(
        "{active}\n\n{BUFFERED_OBSERVATIONS_SEPARATOR}\n\n{buffered}"
    ))
}