remem-ai 0.6.42

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

use crate::user_context::candidates::UserContextCandidateRisk;
use crate::user_context::claims::{UserContextClaimType, UserContextSensitivity};

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ParsedUserContextCandidate {
    pub(crate) claim_type: UserContextClaimType,
    pub(crate) claim_key: String,
    pub(crate) claim_text: String,
    pub(crate) confidence: f64,
    pub(crate) sensitivity: UserContextSensitivity,
    pub(crate) risk_class: UserContextCandidateRisk,
    pub(crate) source_kind: String,
    pub(crate) source_event_ids: Vec<i64>,
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) enum UserContextCandidateResponse {
    NoCandidates,
    Candidates(Vec<ParsedUserContextCandidate>),
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ResponseEnvelope {
    candidates: Option<Vec<JsonCandidate>>,
    no_candidates: Option<NoCandidates>,
}

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

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonCandidate {
    claim_type: String,
    claim_key: String,
    claim_text: String,
    confidence: f64,
    sensitivity: String,
    risk_class: String,
    source_kind: String,
    source_event_ids: Vec<i64>,
}

pub(crate) fn parse_user_context_candidate_response(
    output: &str,
) -> Result<UserContextCandidateResponse> {
    let envelope: ResponseEnvelope = serde_json::from_str(output.trim())
        .context("malformed user_context_candidate output: expected strict JSON object")?;
    match (envelope.candidates, envelope.no_candidates) {
        (Some(_), Some(_)) => bail!(
            "malformed user_context_candidate output: candidates and no_candidates are mutually exclusive"
        ),
        (None, None) => bail!(
            "malformed user_context_candidate output: missing candidates or no_candidates"
        ),
        (None, Some(no_candidates)) => {
            ensure!(
                !no_candidates.reason.trim().is_empty(),
                "malformed user_context_candidate output: no_candidates.reason is required"
            );
            Ok(UserContextCandidateResponse::NoCandidates)
        }
        (Some(candidates), None) => {
            ensure!(
                !candidates.is_empty(),
                "malformed user_context_candidate output: candidates must not be empty"
            );
            candidates
                .into_iter()
                .enumerate()
                .map(|(index, candidate)| validate_candidate(index, candidate))
                .collect::<Result<Vec<_>>>()
                .map(UserContextCandidateResponse::Candidates)
        }
    }
}

fn validate_candidate(
    index: usize,
    candidate: JsonCandidate,
) -> Result<ParsedUserContextCandidate> {
    let claim_type = parse_claim_type(&candidate.claim_type, index)?;
    let claim_key = normalize_claim_key(&candidate.claim_key, index)?;
    let claim_text = normalize_required("claim_text", index, &candidate.claim_text)?;
    let confidence = validate_confidence(candidate.confidence, index)?;
    let sensitivity = parse_sensitivity(&candidate.sensitivity, index)?;
    let risk_class = parse_risk_class(&candidate.risk_class, index)?;
    let source_kind = parse_source_kind(&candidate.source_kind, index)?;
    let source_event_ids = validate_source_event_ids(candidate.source_event_ids, index)?;
    Ok(ParsedUserContextCandidate {
        claim_type,
        claim_key,
        claim_text,
        confidence,
        sensitivity,
        risk_class,
        source_kind,
        source_event_ids,
    })
}

fn parse_claim_type(raw: &str, index: usize) -> Result<UserContextClaimType> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "identity" => Ok(UserContextClaimType::Identity),
        "role" => Ok(UserContextClaimType::Role),
        "preference" => Ok(UserContextClaimType::Preference),
        "skill" => Ok(UserContextClaimType::Skill),
        "goal" => Ok(UserContextClaimType::Goal),
        "project" => Ok(UserContextClaimType::Project),
        "relationship" => Ok(UserContextClaimType::Relationship),
        "constraint" => Ok(UserContextClaimType::Constraint),
        "activity" => Ok(UserContextClaimType::Activity),
        other => bail!(
            "malformed user_context_candidate output: candidate {index} has unsupported claim_type `{other}`"
        ),
    }
}

fn parse_sensitivity(raw: &str, index: usize) -> Result<UserContextSensitivity> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "normal" => Ok(UserContextSensitivity::Normal),
        "personal" => Ok(UserContextSensitivity::Personal),
        "sensitive" => Ok(UserContextSensitivity::Sensitive),
        "restricted" => Ok(UserContextSensitivity::Restricted),
        other => bail!(
            "malformed user_context_candidate output: candidate {index} has unsupported sensitivity `{other}`"
        ),
    }
}

fn parse_risk_class(raw: &str, index: usize) -> Result<UserContextCandidateRisk> {
    match raw.trim().to_ascii_lowercase().as_str() {
        "low" => Ok(UserContextCandidateRisk::Low),
        "medium" => Ok(UserContextCandidateRisk::Medium),
        "high" => Ok(UserContextCandidateRisk::High),
        other => bail!(
            "malformed user_context_candidate output: candidate {index} has unsupported risk_class `{other}`"
        ),
    }
}

fn parse_source_kind(raw: &str, index: usize) -> Result<String> {
    let value = raw.trim().to_ascii_lowercase();
    match value.as_str() {
        "explicit_user_statement"
        | "inferred_from_behavior"
        | "session_summary"
        | "third_party_statement"
        | "speculative_inference" => Ok(value),
        other => bail!(
            "malformed user_context_candidate output: candidate {index} has unsupported source_kind `{other}`"
        ),
    }
}

fn normalize_claim_key(raw: &str, index: usize) -> Result<String> {
    let value = raw.trim().to_ascii_lowercase();
    ensure!(
        !value.is_empty(),
        "malformed user_context_candidate output: candidate {index} claim_key is required"
    );
    ensure!(
        !value.chars().any(char::is_whitespace),
        "malformed user_context_candidate output: candidate {index} claim_key must not contain whitespace"
    );
    Ok(value)
}

fn normalize_required(field: &str, index: usize, raw: &str) -> Result<String> {
    let value = raw.trim();
    ensure!(
        !value.is_empty(),
        "malformed user_context_candidate output: candidate {index} {field} is required"
    );
    Ok(value.to_string())
}

fn validate_confidence(value: f64, index: usize) -> Result<f64> {
    ensure!(
        value.is_finite() && (0.0..=1.0).contains(&value),
        "malformed user_context_candidate output: candidate {index} confidence must be between 0.0 and 1.0"
    );
    Ok(value)
}

fn validate_source_event_ids(values: Vec<i64>, index: usize) -> Result<Vec<i64>> {
    ensure!(
        !values.is_empty(),
        "malformed user_context_candidate output: candidate {index} source_event_ids must not be empty"
    );
    for id in &values {
        ensure!(
            *id > 0,
            "malformed user_context_candidate output: candidate {index} source_event_ids must be positive"
        );
    }
    Ok(values)
}