remem-ai 0.5.150

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::collections::HashSet;

use anyhow::{anyhow, Context, Result};

use super::RollupRange;

#[derive(Debug, Clone)]
pub(super) struct RollupOutput {
    pub(super) summary_text: String,
    pub(super) segments: Vec<ParsedTopicSegment>,
}

#[derive(Debug, Clone)]
pub(super) struct ParsedTopicSegment {
    pub(super) topic_key: String,
    pub(super) title: String,
    pub(super) summary: String,
    pub(super) status: String,
    pub(super) segment_index: i64,
    pub(super) covered_from_event_id: i64,
    pub(super) covered_to_event_id: i64,
    pub(super) evidence_event_ids: Vec<i64>,
    pub(super) files: Vec<String>,
    pub(super) confidence: f64,
}

pub(super) fn parse_rollup_response(text: &str, range: &RollupRange) -> Result<RollupOutput> {
    let summary_text = extract_top_level_summary(text)
        .map(|summary| summary.trim().to_string())
        .filter(|summary| !summary.is_empty())
        .ok_or_else(|| anyhow!("session_rollup response missing non-empty <summary>"))?;

    let segments_xml = extract_tag(text, "segments")
        .ok_or_else(|| anyhow!("session_rollup response missing <segments>"))?;
    if segments_xml.trim().is_empty() {
        return Ok(RollupOutput {
            summary_text,
            segments: Vec::new(),
        });
    }

    let mut segments = Vec::new();
    for (index, raw_segment) in iter_segment_blocks(&segments_xml)?.into_iter().enumerate() {
        let segment = parse_segment(index as i64, &raw_segment, range)
            .map_err(|error| anyhow!("parse topic segment index {index}: {error}"))?;
        segments.push(segment);
    }
    if segments.is_empty() {
        return Err(anyhow!(
            "session_rollup response <segments> contains no <segment> blocks"
        ));
    }
    Ok(RollupOutput {
        summary_text,
        segments,
    })
}

fn iter_segment_blocks(text: &str) -> Result<Vec<String>> {
    let mut blocks = Vec::new();
    let mut rest = text;
    while let Some(start_rel) = rest.find("<segment") {
        let after_start = &rest[start_rel..];
        let open_end_rel = after_start
            .find('>')
            .context("segment missing opening tag terminator")?;
        let body_start = start_rel + open_end_rel + 1;
        let close_rel = rest[body_start..]
            .find("</segment>")
            .context("segment missing closing tag")?;
        let close_end = body_start + close_rel + "</segment>".len();
        blocks.push(rest[start_rel..close_end].to_string());
        rest = &rest[close_end..];
    }
    Ok(blocks)
}

fn extract_top_level_summary(text: &str) -> Option<String> {
    let segments_start = text.find("<segments").unwrap_or(text.len());
    extract_tag(&text[..segments_start], "summary")
}

fn parse_segment(
    segment_index: i64,
    raw_segment: &str,
    range: &RollupRange,
) -> Result<ParsedTopicSegment> {
    let open_end = raw_segment
        .find('>')
        .context("segment missing opening tag terminator")?;
    let open_tag = &raw_segment[..=open_end];
    let body = &raw_segment[open_end + 1..raw_segment.len() - "</segment>".len()];

    let topic_key = extract_attr(open_tag, "topic_key")
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .context("segment missing topic_key")?;
    if !is_valid_topic_key(&topic_key) {
        return Err(anyhow!("invalid topic_key '{topic_key}'"));
    }

    let status = extract_attr(open_tag, "status").unwrap_or_else(|| "open".to_string());
    if !matches!(status.as_str(), "open" | "resolved" | "superseded") {
        return Err(anyhow!("invalid segment status '{status}'"));
    }

    let title = required_tag(body, "title")?;
    let summary = required_tag(body, "summary")?;
    let explicit_from = required_i64(body, "from_event_id")?;
    let explicit_to = required_i64(body, "to_event_id")?;
    if explicit_to < explicit_from {
        return Err(anyhow!(
            "to_event_id {} is before from_event_id {}",
            explicit_to,
            explicit_from
        ));
    }

    let mut evidence_event_ids = extract_tag(body, "evidence_event_ids")
        .map(|raw| parse_event_ids(&raw))
        .transpose()?
        .unwrap_or_else(|| vec![explicit_from, explicit_to]);
    evidence_event_ids.sort_unstable();
    evidence_event_ids.dedup();
    if evidence_event_ids.is_empty() {
        return Err(anyhow!("segment has empty evidence_event_ids"));
    }
    let loaded_event_ids = range
        .events
        .iter()
        .map(|event| event.id)
        .collect::<HashSet<_>>();
    let missing_event_ids = evidence_event_ids
        .iter()
        .copied()
        .filter(|event_id| !loaded_event_ids.contains(event_id))
        .collect::<Vec<_>>();
    if !missing_event_ids.is_empty() {
        return Err(anyhow!(
            "evidence_event_ids absent from loaded rollup events: {:?}",
            missing_event_ids
        ));
    }
    let covered_from_event_id = evidence_event_ids[0];
    let covered_to_event_id = evidence_event_ids[evidence_event_ids.len() - 1];

    let files = extract_tag(body, "files")
        .map(|raw| parse_files(&raw))
        .transpose()?
        .unwrap_or_default();
    let confidence = extract_attr(open_tag, "confidence")
        .as_deref()
        .map(str::parse::<f64>)
        .transpose()
        .context("parse segment confidence")?
        .unwrap_or(0.75);

    Ok(ParsedTopicSegment {
        topic_key,
        title,
        summary,
        status,
        segment_index,
        covered_from_event_id,
        covered_to_event_id,
        evidence_event_ids,
        files,
        confidence,
    })
}

fn required_tag(body: &str, tag: &str) -> Result<String> {
    extract_tag(body, tag)
        .map(|value| value.trim().to_string())
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow!("segment missing non-empty <{tag}>"))
}

fn required_i64(body: &str, tag: &str) -> Result<i64> {
    required_tag(body, tag)?
        .parse::<i64>()
        .with_context(|| format!("parse <{tag}>"))
}

fn extract_tag(text: &str, tag: &str) -> Option<String> {
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");
    let start = text.find(&open)? + open.len();
    let end = text[start..].find(&close)? + start;
    Some(xml_unescape_text(text[start..end].trim()))
}

fn extract_attr(open_tag: &str, attr: &str) -> Option<String> {
    let needle = format!("{attr}=\"");
    let start = open_tag.find(&needle)? + needle.len();
    let end = open_tag[start..].find('"')? + start;
    Some(xml_unescape_text(&open_tag[start..end]))
}

fn parse_event_ids(raw: &str) -> Result<Vec<i64>> {
    if let Ok(ids) = serde_json::from_str::<Vec<i64>>(raw.trim()) {
        return Ok(ids);
    }
    raw.split(|ch: char| ch == ',' || ch.is_whitespace())
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(|part| {
            part.parse::<i64>()
                .with_context(|| format!("parse evidence event id '{part}'"))
        })
        .collect()
}

fn parse_files(raw: &str) -> Result<Vec<String>> {
    if raw.trim().is_empty() {
        return Ok(Vec::new());
    }
    if let Ok(files) = serde_json::from_str::<Vec<String>>(raw.trim()) {
        return Ok(files);
    }
    let mut files = raw
        .split([',', '\n'])
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(str::to_string)
        .collect::<Vec<_>>();
    files.sort();
    files.dedup();
    Ok(files)
}

fn is_valid_topic_key(value: &str) -> bool {
    value
        .chars()
        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_')
}

fn xml_unescape_text(raw: &str) -> String {
    raw.replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&apos;", "'")
        .replace("&amp;", "&")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session_rollup::{RollupEvent, RollupRange};

    fn event(id: i64) -> RollupEvent {
        RollupEvent {
            id,
            event_type: "tool_result".to_string(),
            role: None,
            tool_name: None,
            content: format!("content {id}"),
            token_estimate: 1,
            created_at_epoch: 100 + id,
            turn_id: None,
        }
    }

    fn range() -> RollupRange {
        RollupRange {
            from_event_id: 10,
            to_event_id: 20,
            events: vec![event(10), event(11), event(14), event(20)],
        }
    }

    #[test]
    fn parses_segments_with_overlapping_event_ranges() -> Result<()> {
        let parsed = parse_rollup_response(
            r#"<summary>done</summary>
            <segments>
            <segment topic_key="anti-bot-research" status="resolved">
              <title>Anti-bot research</title>
              <summary>Investigated blocking.</summary>
              <evidence_event_ids>10,14,20</evidence_event_ids>
              <from_event_id>10</from_event_id>
              <to_event_id>20</to_event_id>
              <files>src/a.rs,src/b.rs</files>
            </segment>
            <segment topic_key="kexue-scraping" status="open">
              <title>Kexue scraping</title>
              <summary>Implemented scraper.</summary>
              <evidence_event_ids>[11, 14]</evidence_event_ids>
              <from_event_id>11</from_event_id>
              <to_event_id>14</to_event_id>
            </segment>
            </segments>"#,
            &range(),
        )?;

        assert_eq!(parsed.summary_text, "done");
        assert_eq!(parsed.segments.len(), 2);
        assert_eq!(parsed.segments[0].covered_from_event_id, 10);
        assert_eq!(parsed.segments[0].covered_to_event_id, 20);
        assert_eq!(parsed.segments[1].evidence_event_ids, vec![11, 14]);
        Ok(())
    }

    #[test]
    fn rejects_segment_with_evidence_event_absent_from_loaded_events() {
        let err = parse_rollup_response(
            r#"<summary>done</summary>
            <segments>
            <segment topic_key="interleaved-session" status="open">
              <title>Interleaved session</title>
              <summary>Should not attach unrelated evidence.</summary>
              <evidence_event_ids>10,15,20</evidence_event_ids>
              <from_event_id>10</from_event_id>
              <to_event_id>20</to_event_id>
            </segment>
            </segments>"#,
            &range(),
        )
        .expect_err("out-of-range evidence should fail the rollup parse");

        assert!(err
            .to_string()
            .contains("evidence_event_ids absent from loaded rollup events"));
    }

    #[test]
    fn missing_segments_tag_fails_entire_rollup_parse() {
        let err = parse_rollup_response("<summary>done</summary>", &range())
            .expect_err("missing segments should fail");
        assert!(err.to_string().contains("missing <segments>"));
    }

    #[test]
    fn explicit_empty_segments_is_accepted() -> Result<()> {
        let parsed =
            parse_rollup_response("<summary>done</summary><segments></segments>", &range())?;

        assert_eq!(parsed.summary_text, "done");
        assert!(parsed.segments.is_empty());
        Ok(())
    }

    #[test]
    fn missing_summary_fails_entire_rollup_parse() {
        let err = parse_rollup_response("<segments></segments>", &range())
            .expect_err("missing summary should fail");
        assert!(err.to_string().contains("missing non-empty <summary>"));
    }

    #[test]
    fn segment_summary_does_not_replace_top_level_summary() {
        let err = parse_rollup_response(
            r#"<segments>
            <segment topic_key="nested-summary" status="open">
              <title>Nested summary</title>
              <summary>This is not the session summary.</summary>
              <evidence_event_ids>10,11</evidence_event_ids>
              <from_event_id>10</from_event_id>
              <to_event_id>11</to_event_id>
            </segment>
            </segments>"#,
            &range(),
        )
        .expect_err("nested segment summary must not satisfy top-level summary");
        assert!(err.to_string().contains("missing non-empty <summary>"));
    }
}