remem-ai 0.6.14

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

use anyhow::{Context, Result};
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use rusqlite::{params, Connection, OptionalExtension};

use super::super::helpers::error_response;
use super::super::types::{
    CandidateDetailItem, CandidateDetailResponse, CandidateEvidenceItem, CandidateReviewDecision,
    DbState,
};

pub(super) struct CandidateDetailProjection {
    pub response: CandidateDetailResponse,
}

pub(in crate::api) async fn handle_candidate_detail(
    State(_state): State<DbState>,
    Path(id): Path<i64>,
) -> impl IntoResponse {
    let conn = match crate::db::open_db() {
        Ok(conn) => conn,
        Err(_) => {
            return error_response(
                StatusCode::INTERNAL_SERVER_ERROR,
                "candidate_detail_failed",
                "candidate detail could not be evaluated safely",
            )
            .into_response()
        }
    };
    match load_candidate_detail(&conn, id) {
        Ok(Some(projection)) => Json(projection.response).into_response(),
        Ok(None) => error_response(
            StatusCode::NOT_FOUND,
            "not_found",
            &format!("candidate {id} not found"),
        )
        .into_response(),
        Err(_) => error_response(
            StatusCode::INTERNAL_SERVER_ERROR,
            "candidate_detail_failed",
            "candidate detail could not be evaluated safely",
        )
        .into_response(),
    }
}

pub(super) fn load_candidate_detail(
    conn: &Connection,
    id: i64,
) -> Result<Option<CandidateDetailProjection>> {
    let row = conn
        .query_row(
            "SELECT c.id, c.project_id, p.project_path, c.scope, c.memory_type,
                    c.topic_key, c.text, c.source_kind, c.source_project,
                    c.target_project, c.owner_scope, c.owner_key, c.topic_domain,
                    c.routing_confidence, c.routing_reason, c.context_class,
                    c.confidence, c.risk_class, c.review_status,
                    c.auto_promote_block_reason, c.source_trust_class,
                    c.quarantine_pattern_id, c.quarantine_pattern_version,
                    c.version, c.created_at_epoch, c.updated_at_epoch,
                    c.evidence_event_ids
             FROM memory_candidates c
             LEFT JOIN projects p ON p.id = c.project_id
             WHERE c.id = ?1",
            params![id],
            |row| {
                Ok((
                    CandidateDetailItem {
                        id: row.get(0)?,
                        project: row.get(2)?,
                        scope: row.get(3)?,
                        memory_type: row.get(4)?,
                        topic_key: row.get(5)?,
                        text: row.get(6)?,
                        source_kind: row.get(7)?,
                        source_project: row.get(8)?,
                        target_project: row.get(9)?,
                        owner_scope: row.get(10)?,
                        owner_key: row.get(11)?,
                        topic_domain: row.get(12)?,
                        routing_confidence: row.get(13)?,
                        routing_reason: row.get(14)?,
                        context_class: row.get(15)?,
                        confidence: row.get(16)?,
                        risk_class: row.get(17)?,
                        review_status: row.get(18)?,
                        auto_promote_block_reason: row.get(19)?,
                        source_trust_class: row.get(20)?,
                        quarantine_pattern_id: row.get(21)?,
                        quarantine_pattern_version: row.get(22)?,
                        version: row.get(23)?,
                        created_at_epoch: row.get(24)?,
                        updated_at_epoch: row.get(25)?,
                    },
                    row.get::<_, Option<i64>>(1)?,
                    row.get::<_, String>(26)?,
                ))
            },
        )
        .optional()
        .context("load candidate detail")?;
    let Some((mut candidate, project_id, evidence_json)) = row else {
        return Ok(None);
    };

    let suppressions = load_active_suppressions(conn)?;
    let candidate_suppressed = candidate_is_suppressed(&candidate, &suppressions);
    redact_candidate_fields(&mut candidate);
    let mut blocked = Vec::new();
    if !matches!(
        candidate.review_status.as_str(),
        "pending_review" | "quarantined"
    ) {
        push_blocked_reason(&mut blocked, "candidate_not_reviewable");
    }
    if project_id.is_none() {
        push_blocked_reason(&mut blocked, "candidate_project_unavailable");
    }
    if candidate_suppressed {
        push_blocked_reason(&mut blocked, "candidate_policy_suppressed");
    }

    let evidence = load_evidence(
        conn,
        project_id,
        &evidence_json,
        &suppressions,
        &mut blocked,
    )?;
    Ok(Some(CandidateDetailProjection {
        response: CandidateDetailResponse {
            data: candidate,
            evidence,
            decision: CandidateReviewDecision {
                can_review: blocked.is_empty(),
                blocked_reasons: blocked,
            },
        },
    }))
}

#[derive(Debug)]
struct ActiveSuppression {
    kind: String,
    target_value: Option<String>,
}

fn load_active_suppressions(conn: &Connection) -> Result<Vec<ActiveSuppression>> {
    let mut stmt = conn.prepare(
        "SELECT target_kind, target_value
         FROM memory_suppressions WHERE status = 'active' ORDER BY id",
    )?;
    let rows = stmt.query_map([], |row| {
        Ok(ActiveSuppression {
            kind: row.get(0)?,
            target_value: row.get(1)?,
        })
    })?;
    rows.collect::<std::result::Result<Vec<_>, _>>()
        .map_err(Into::into)
}

fn candidate_is_suppressed(
    candidate: &CandidateDetailItem,
    suppressions: &[ActiveSuppression],
) -> bool {
    suppressions
        .iter()
        .any(|suppression| match suppression.kind.as_str() {
            "topic_key" => {
                suppression.target_value.as_deref() == Some(candidate.topic_key.as_str())
            }
            "pattern" => suppression.target_value.as_deref().is_some_and(|pattern| {
                candidate_text_contains_pattern(&candidate.text, pattern)
                    || candidate_text_contains_pattern(&candidate.topic_key, pattern)
            }),
            _ => false,
        })
}

fn load_evidence(
    conn: &Connection,
    candidate_project_id: Option<i64>,
    evidence_json: &str,
    suppressions: &[ActiveSuppression],
    blocked: &mut Vec<String>,
) -> Result<Vec<CandidateEvidenceItem>> {
    let ids = match parse_candidate_evidence_ids(evidence_json) {
        Ok(ids) => ids,
        Err(reason) => {
            push_blocked_reason(blocked, reason);
            return Ok(Vec::new());
        }
    };
    if ids.is_empty() {
        push_blocked_reason(blocked, "evidence_required");
        return Ok(Vec::new());
    }
    let mut evidence = Vec::with_capacity(ids.len());
    for id in ids {
        let row = conn
            .query_row(
                "SELECT project_id, event_type, role, tool_name, created_at_epoch
                 FROM captured_events WHERE id = ?1",
                params![id],
                |row| {
                    Ok((
                        row.get::<_, i64>(0)?,
                        row.get::<_, String>(1)?,
                        row.get::<_, Option<String>>(2)?,
                        row.get::<_, Option<String>>(3)?,
                        row.get::<_, i64>(4)?,
                    ))
                },
            )
            .optional()?;
        let Some((event_project_id, event_type, role, tool_name, created_at_epoch)) = row else {
            push_blocked_reason(blocked, "evidence_missing");
            evidence.push(unavailable_evidence(id, "missing"));
            continue;
        };
        if candidate_project_id != Some(event_project_id) {
            push_blocked_reason(blocked, "evidence_cross_project");
            evidence.push(unavailable_evidence(id, "cross_project"));
            continue;
        }
        let Some((event_type_label, summary)) = safe_event_summary(&event_type) else {
            push_blocked_reason(blocked, "evidence_safe_projection_unavailable");
            evidence.push(CandidateEvidenceItem {
                source_kind: "captured_event",
                source_id: id,
                event_type: safe_event_type(&event_type),
                role: safe_role(role.as_deref()),
                tool_name: safe_tool_name(tool_name.as_deref()),
                created_at_epoch: Some(created_at_epoch),
                summary: String::new(),
                preview: String::new(),
                provenance_status: "unsafe_projection".to_string(),
                redacted: true,
            });
            continue;
        };
        if suppression_matches_text(suppressions, summary) {
            push_blocked_reason(blocked, "evidence_policy_suppressed");
            evidence.push(unavailable_evidence(id, "suppressed"));
            continue;
        }
        evidence.push(CandidateEvidenceItem {
            source_kind: "captured_event",
            source_id: id,
            event_type: Some(event_type_label.to_string()),
            role: safe_role(role.as_deref()),
            tool_name: safe_tool_name(tool_name.as_deref()),
            created_at_epoch: Some(created_at_epoch),
            summary: summary.to_string(),
            preview: String::new(),
            provenance_status: "verified".to_string(),
            redacted: true,
        });
    }
    Ok(evidence)
}

fn parse_candidate_evidence_ids(raw: &str) -> std::result::Result<Vec<i64>, &'static str> {
    let values: Vec<serde_json::Value> =
        serde_json::from_str(raw).map_err(|_| "evidence_ids_invalid")?;
    let mut seen = HashSet::new();
    let mut ids = Vec::with_capacity(values.len());
    for value in values {
        let id = value
            .as_i64()
            .filter(|id| *id > 0)
            .ok_or("evidence_id_invalid")?;
        if !seen.insert(id) {
            return Err("evidence_id_duplicate");
        }
        ids.push(id);
    }
    Ok(ids)
}

fn safe_event_summary(event_type: &str) -> Option<(&'static str, &'static str)> {
    match event_type {
        "file_edit" => Some(("file_edit", "File edit evidence")),
        "file_create" => Some(("file_create", "File creation evidence")),
        "search" => Some(("search", "Search evidence")),
        "bash" => Some(("bash", "Shell command evidence")),
        _ => None,
    }
}

fn safe_event_type(value: &str) -> Option<String> {
    matches!(
        value,
        "file_edit" | "file_create" | "search" | "bash" | "session_stop"
    )
    .then(|| value.to_string())
}

fn safe_role(value: Option<&str>) -> Option<String> {
    value
        .filter(|value| matches!(*value, "user" | "assistant" | "tool" | "system"))
        .map(str::to_string)
}

fn safe_tool_name(value: Option<&str>) -> Option<String> {
    value
        .filter(|value| {
            matches!(
                *value,
                "Edit" | "Write" | "NotebookEdit" | "Bash" | "Grep" | "Glob"
            )
        })
        .map(str::to_string)
}

fn unavailable_evidence(id: i64, status: &str) -> CandidateEvidenceItem {
    CandidateEvidenceItem {
        source_kind: "captured_event",
        source_id: id,
        event_type: None,
        role: None,
        tool_name: None,
        created_at_epoch: None,
        summary: String::new(),
        preview: String::new(),
        provenance_status: status.to_string(),
        redacted: true,
    }
}

fn suppression_matches_text(suppressions: &[ActiveSuppression], text: &str) -> bool {
    suppressions.iter().any(|suppression| {
        suppression.kind == "pattern"
            && suppression
                .target_value
                .as_deref()
                .is_some_and(|pattern| candidate_text_contains_pattern(text, pattern))
    })
}

fn candidate_text_contains_pattern(text: &str, pattern: &str) -> bool {
    text.to_lowercase().contains(&pattern.to_lowercase())
}

fn redact_candidate_fields(candidate: &mut CandidateDetailItem) {
    candidate.text = crate::adapter::common::redact_sensitive_text(&candidate.text);
    candidate.routing_reason = candidate
        .routing_reason
        .take()
        .map(|value| crate::adapter::common::redact_sensitive_text(&value));
}

fn push_blocked_reason(reasons: &mut Vec<String>, reason: &str) {
    if !reasons.iter().any(|existing| existing == reason) {
        reasons.push(reason.to_string());
    }
}