remem-ai 0.5.148

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

use crate::db::models::OBSERVATION_TYPES;
use crate::memory::format::ParsedObservation;

#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ObservationExtractResponse {
    NoObservations,
    Observations(Vec<ParsedObservation>),
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ResponseEnvelope {
    observations: Option<Vec<JsonObservation>>,
    no_observations: Option<NoObservations>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct NoObservations {
    reason: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonObservation {
    #[serde(rename = "type")]
    obs_type: String,
    title: serde_json::Value,
    subtitle: serde_json::Value,
    narrative: serde_json::Value,
    facts: Vec<String>,
    concepts: Vec<String>,
    files_read: Vec<String>,
    files_modified: Vec<String>,
    confidence: f64,
}

pub(crate) fn parse_observation_extract_response(
    output: &str,
) -> Result<ObservationExtractResponse> {
    let envelope: ResponseEnvelope = serde_json::from_str(output.trim())
        .context("malformed observation_extract output: expected strict JSON object")?;
    match (envelope.observations, envelope.no_observations) {
        (Some(_), Some(_)) => bail!(
            "malformed observation_extract output: observations and no_observations are mutually exclusive"
        ),
        (None, None) => bail!(
            "malformed observation_extract output: missing observations or no_observations"
        ),
        (None, Some(no_observations)) => {
            ensure!(
                !no_observations.reason.trim().is_empty(),
                "malformed observation_extract output: no_observations.reason is required"
            );
            Ok(ObservationExtractResponse::NoObservations)
        }
        (Some(observations), None) => {
            ensure!(
                !observations.is_empty(),
                "malformed observation_extract output: observations must not be empty"
            );
            observations
                .into_iter()
                .enumerate()
                .map(|(index, observation)| validate_observation(index, observation))
                .collect::<Result<Vec<_>>>()
                .map(ObservationExtractResponse::Observations)
        }
    }
}

fn validate_observation(index: usize, observation: JsonObservation) -> Result<ParsedObservation> {
    let obs_type = observation.obs_type.trim().to_ascii_lowercase();
    ensure!(
        OBSERVATION_TYPES.contains(&obs_type.as_str()),
        "malformed observation_extract output: observation {index} has unsupported type `{}`",
        observation.obs_type
    );
    ensure!(
        observation.confidence.is_finite()
            && (0.0..=1.0).contains(&observation.confidence),
        "malformed observation_extract output: observation {index} confidence must be between 0.0 and 1.0"
    );

    let title = validate_observation_optional_text("title", index, observation.title)?;
    let subtitle = validate_observation_optional_text("subtitle", index, observation.subtitle)?;
    let narrative = validate_observation_optional_text("narrative", index, observation.narrative)?;
    let facts = validate_observation_text_list("facts", index, observation.facts)?;
    let concepts = validate_observation_text_list("concepts", index, observation.concepts)?;
    let files_read = validate_observation_text_list("files_read", index, observation.files_read)?;
    let files_modified =
        validate_observation_text_list("files_modified", index, observation.files_modified)?;

    ensure!(
        title.is_some() || narrative.is_some() || !facts.is_empty(),
        "malformed observation_extract output: observation {index} must include title, narrative, or facts"
    );

    Ok(ParsedObservation {
        obs_type,
        title,
        subtitle,
        facts,
        narrative,
        concepts,
        files_read,
        files_modified,
        confidence: Some(observation.confidence),
    })
}

fn validate_observation_optional_text(
    field: &str,
    index: usize,
    value: serde_json::Value,
) -> Result<Option<String>> {
    match value {
        serde_json::Value::Null => Ok(None),
        serde_json::Value::String(value) => {
            let trimmed = value.trim();
            ensure!(
                !trimmed.is_empty(),
                "malformed observation_extract output: observation {index} {field} must not be blank"
            );
            Ok(Some(trimmed.to_string()))
        }
        _ => bail!(
            "malformed observation_extract output: observation {index} {field} must be a string or null"
        ),
    }
}

fn validate_observation_text_list(
    field: &str,
    index: usize,
    values: Vec<String>,
) -> Result<Vec<String>> {
    values
        .into_iter()
        .enumerate()
        .map(|(item_index, value)| {
            let trimmed = value.trim();
            ensure!(
                !trimmed.is_empty(),
                "malformed observation_extract output: observation {index} {field}[{item_index}] must not be blank"
            );
            Ok(trimmed.to_string())
        })
        .collect()
}

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

    #[test]
    fn parses_observations_json() -> Result<()> {
        let parsed = parse_observation_extract_response(
            r#"{
              "observations": [{
                "type": "decision",
                "title": "Use automatic capture",
                "subtitle": null,
                "narrative": "Automatic capture remains the primary path.",
                "facts": ["Manual save is supplemental."],
                "concepts": ["automatic capture"],
                "files_read": [],
                "files_modified": [],
                "confidence": 0.91
              }]
            }"#,
        )?;

        match parsed {
            ObservationExtractResponse::Observations(observations) => {
                assert_eq!(observations.len(), 1);
                assert_eq!(observations[0].obs_type, "decision");
                assert_eq!(observations[0].confidence, Some(0.91));
            }
            ObservationExtractResponse::NoObservations => panic!("expected observation"),
        }
        Ok(())
    }

    #[test]
    fn parses_explicit_no_observations_json() -> Result<()> {
        let parsed = parse_observation_extract_response(
            r#"{"no_observations":{"reason":"low signal command output"}}"#,
        )?;
        assert_eq!(parsed, ObservationExtractResponse::NoObservations);
        Ok(())
    }

    #[test]
    fn rejects_legacy_xml_output() {
        let err = parse_observation_extract_response(
            "<observation><type>discovery</type><narrative>legacy</narrative></observation>",
        )
        .expect_err("legacy xml must fail closed");
        assert!(err.to_string().contains("strict JSON object"));
    }

    #[test]
    fn rejects_unknown_fields() {
        let err = parse_observation_extract_response(
            r#"{
              "observations": [{
                "type": "decision",
                "title": "Known",
                "subtitle": null,
                "narrative": "Known",
                "facts": [],
                "concepts": [],
                "files_read": [],
                "files_modified": [],
                "confidence": 0.91,
                "extra": "nope"
              }]
            }"#,
        )
        .expect_err("unknown fields must fail closed");
        assert!(format!("{err:#}").contains("unknown field"));
    }

    #[test]
    fn rejects_missing_nullable_observation_fields() {
        let err = parse_observation_extract_response(
            r#"{
              "observations": [{
                "type": "decision",
                "subtitle": null,
                "narrative": "Known",
                "facts": [],
                "concepts": [],
                "files_read": [],
                "files_modified": [],
                "confidence": 0.91
              }]
            }"#,
        )
        .expect_err("missing title key must fail closed");
        assert!(format!("{err:#}").contains("missing field `title`"));
    }

    #[test]
    fn rejects_unsupported_types() {
        let err = parse_observation_extract_response(
            r#"{
              "observations": [{
                "type": "preference",
                "title": "No alias",
                "subtitle": null,
                "narrative": "Unsupported types must not default.",
                "facts": [],
                "concepts": [],
                "files_read": [],
                "files_modified": [],
                "confidence": 0.91
              }]
            }"#,
        )
        .expect_err("unsupported type must fail");
        assert!(err.to_string().contains("unsupported type"));
    }

    #[test]
    fn rejects_out_of_range_confidence() {
        let err = parse_observation_extract_response(
            r#"{
              "observations": [{
                "type": "decision",
                "title": "Bad confidence",
                "subtitle": null,
                "narrative": "Confidence is invalid.",
                "facts": [],
                "concepts": [],
                "files_read": [],
                "files_modified": [],
                "confidence": 1.7
              }]
            }"#,
        )
        .expect_err("out-of-range confidence must fail");
        assert!(err.to_string().contains("confidence must be between"));
    }
}