remem-ai 0.5.153

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use anyhow::{bail, Context, Result};

use super::ParsedGraphCandidate;
use crate::memory::format::{extract_field, find_ascii_ci};

pub(super) fn parse_graph_candidates(text: &str) -> Result<Vec<ParsedGraphCandidate>> {
    let mut candidates = Vec::new();
    let mut pos = 0;
    while let Some(tag_start_rel) = find_ascii_ci(&text[pos..], "<graph_candidate") {
        let tag_start = pos + tag_start_rel;
        let Some(open_end_rel) = text[tag_start..].find('>') else {
            bail!("malformed graph_candidate output: unterminated opening tag");
        };
        let content_start = tag_start + open_end_rel + 1;
        let Some(close_rel) = find_ascii_ci(&text[content_start..], "</graph_candidate>") else {
            bail!("malformed graph_candidate output: missing closing tag");
        };
        let content_end = content_start + close_rel;
        candidates.push(parse_graph_candidate_content(
            &text[content_start..content_end],
        )?);
        pos = content_end + "</graph_candidate>".len();
    }
    Ok(candidates)
}

pub(super) fn parse_graph_defer_reason(text: &str) -> Option<String> {
    let tag_start = find_ascii_ci(text, "<defer")?;
    let open_end = text[tag_start..].find('>')?;
    let opening = &text[tag_start..tag_start + open_end + 1];
    graph_attr_value(opening, "reason")
        .map(str::trim)
        .filter(|reason| !reason.is_empty())
        .map(str::to_string)
}

fn parse_graph_candidate_content(content: &str) -> Result<ParsedGraphCandidate> {
    let candidate_type =
        normalize_graph_candidate_type(graph_required_field(content, "type")?.as_str())?;
    let edge_type = normalize_graph_edge_type(
        &candidate_type,
        graph_required_field(content, "edge_type")?.as_str(),
    )?;
    let from_ref = normalize_graph_ref(graph_required_field(content, "from_ref")?.as_str())?;
    let to_ref = normalize_graph_ref(graph_required_field(content, "to_ref")?.as_str())?;
    let evidence_event_ids =
        parse_graph_evidence_ids(graph_required_field(content, "evidence_event_ids")?.as_str())?;
    let risk_class =
        normalize_graph_risk_class(graph_required_field(content, "risk_class")?.as_str())?;
    let confidence = parse_graph_confidence(graph_required_field(content, "confidence")?.as_str())?;
    let reason = normalize_graph_reason(graph_required_field(content, "reason")?.as_str())?;
    validate_graph_candidate_shape(&candidate_type, &edge_type, &to_ref)?;
    Ok(ParsedGraphCandidate {
        candidate_type,
        edge_type,
        from_ref,
        to_ref,
        evidence_event_ids,
        confidence,
        risk_class,
        reason,
    })
}

fn graph_required_field(content: &str, field: &str) -> Result<String> {
    extract_field(content, field)
        .with_context(|| format!("malformed graph_candidate output: missing <{field}>"))
}

fn normalize_graph_candidate_type(raw: &str) -> Result<String> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "edge" => Ok(raw.trim().to_ascii_lowercase()),
        other => bail!("malformed graph_candidate output: invalid type '{other}'"),
    }
}

fn normalize_graph_edge_type(candidate_type: &str, raw: &str) -> Result<String> {
    let value = raw.trim().to_ascii_lowercase();
    match candidate_type {
        "edge" => match value.as_str() {
            "mentions" | "touches_file" | "conflicts" => Ok(value),
            other => bail!("malformed graph_candidate output: invalid edge_type '{other}'"),
        },
        _ => bail!("malformed graph_candidate output: invalid type '{candidate_type}'"),
    }
}

fn normalize_graph_ref(raw: &str) -> Result<String> {
    let value = raw.trim();
    if value.is_empty() {
        bail!("malformed graph_candidate output: empty ref");
    }
    if value.len() > 512 {
        bail!("malformed graph_candidate output: ref too long");
    }
    if value.chars().any(|ch| ch == '\n' || ch == '\r') {
        bail!("malformed graph_candidate output: ref must be single-line");
    }
    let Some((prefix, rest)) = value.split_once(':') else {
        bail!("malformed graph_candidate output: ref must use '<kind>:<value>'");
    };
    if rest.trim().is_empty() {
        bail!("malformed graph_candidate output: empty ref value");
    }
    match prefix {
        "memory" | "entity" | "episode" | "file" | "state" | "claim" | "project" => {
            Ok(value.to_string())
        }
        other => bail!("malformed graph_candidate output: invalid ref kind '{other}'"),
    }
}

fn parse_graph_evidence_ids(raw: &str) -> Result<Vec<i64>> {
    let mut ids = raw
        .split(|ch: char| ch == ',' || ch.is_ascii_whitespace())
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(|part| {
            part.parse::<i64>()
                .with_context(|| "malformed graph_candidate output: invalid evidence_event_ids")
        })
        .collect::<Result<Vec<_>>>()?;
    ids.sort_unstable();
    ids.dedup();
    if ids.is_empty() {
        bail!("malformed graph_candidate output: empty evidence_event_ids");
    }
    if ids.iter().any(|id| *id <= 0) {
        bail!("malformed graph_candidate output: evidence_event_ids must be positive");
    }
    Ok(ids)
}

fn normalize_graph_risk_class(raw: &str) -> Result<String> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "low" | "medium" | "high" => Ok(raw.trim().to_ascii_lowercase()),
        other => bail!("malformed graph_candidate output: invalid risk_class '{other}'"),
    }
}

fn parse_graph_confidence(raw: &str) -> Result<f64> {
    let confidence: f64 = raw
        .trim()
        .parse()
        .with_context(|| "malformed graph_candidate output: invalid confidence")?;
    if !(0.0..=1.0).contains(&confidence) {
        bail!("malformed graph_candidate output: confidence out of range");
    }
    Ok(confidence)
}

fn normalize_graph_reason(raw: &str) -> Result<String> {
    let reason = raw.trim();
    if reason.is_empty() {
        bail!("malformed graph_candidate output: empty reason");
    }
    Ok(reason.to_string())
}

fn validate_graph_candidate_shape(
    _candidate_type: &str,
    edge_type: &str,
    to_ref: &str,
) -> Result<()> {
    if edge_type == "touches_file" && !to_ref.starts_with("file:") {
        bail!("malformed graph_candidate output: touches_file to_ref must be file:<path>");
    }
    Ok(())
}

fn graph_attr_value<'a>(tag: &'a str, attr: &str) -> Option<&'a str> {
    let needle = format!("{attr}=\"");
    let value_start = tag.find(&needle)? + needle.len();
    let value_end = tag[value_start..].find('"')?;
    Some(&tag[value_start..value_start + value_end])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_multiple_graph_candidates() -> Result<()> {
        let rows = parse_graph_candidates(
            "<graph_candidate><type>edge</type><edge_type>mentions</edge_type><from_ref>memory:1</from_ref><to_ref>entity:Worker</to_ref><evidence_event_ids>2,1,1</evidence_event_ids><risk_class>low</risk_class><confidence>0.91</confidence><reason>Observation names the worker.</reason></graph_candidate>",
        )?;

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].edge_type, "mentions");
        assert_eq!(rows[0].evidence_event_ids, vec![1, 2]);
        Ok(())
    }

    #[test]
    fn malformed_ref_fails_closed() {
        let err = parse_graph_candidates(
            "<graph_candidate><type>edge</type><edge_type>mentions</edge_type><from_ref>memory</from_ref><to_ref>entity:Worker</to_ref><evidence_event_ids>1</evidence_event_ids><risk_class>low</risk_class><confidence>0.91</confidence><reason>bad ref</reason></graph_candidate>",
        )
        .expect_err("bad ref should fail");

        assert!(err.to_string().contains("malformed graph_candidate"));
    }

    #[test]
    fn unpromotable_edge_type_fails_closed() {
        let err = parse_graph_candidates(
			"<graph_candidate><type>edge</type><edge_type>supports</edge_type><from_ref>memory:1</from_ref><to_ref>memory:2</to_ref><evidence_event_ids>1</evidence_event_ids><risk_class>low</risk_class><confidence>0.91</confidence><reason>unsupported edge</reason></graph_candidate>",
        )
        .expect_err("unsupported edge type should fail");

        assert!(err.to_string().contains("invalid edge_type 'supports'"));
    }

    #[test]
    fn unpromotable_candidate_type_fails_closed() {
        let result = parse_graph_candidates(
			"<graph_candidate><type>state_relation</type><edge_type>current_state</edge_type><from_ref>memory:1</from_ref><to_ref>state:active_focus</to_ref><evidence_event_ids>1</evidence_event_ids><risk_class>low</risk_class><confidence>0.91</confidence><reason>unsupported candidate type</reason></graph_candidate>",
		);
        let err = match result {
            Ok(_) => panic!("unsupported candidate type should fail"),
            Err(err) => err,
        };

        assert!(err.to_string().contains("invalid type 'state_relation'"));
    }

    #[test]
    fn episode_ref_is_valid_source_ref() -> Result<()> {
        let rows = parse_graph_candidates(
            "<graph_candidate><type>edge</type><edge_type>mentions</edge_type><from_ref>episode:42</from_ref><to_ref>entity:Worker</to_ref><evidence_event_ids>42</evidence_event_ids><risk_class>low</risk_class><confidence>0.91</confidence><reason>event mentions Worker.</reason></graph_candidate>",
        )?;

        assert_eq!(rows[0].from_ref, "episode:42");
        Ok(())
    }

    #[test]
    fn parses_graph_defer_reason_from_self_closing_tag() {
        assert_eq!(
            parse_graph_defer_reason("<defer reason=\"ambiguous alias\"/>"),
            Some("ambiguous alias".to_string())
        );
    }
}