remem-ai 0.6.36

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

use crate::memory::poisoning::{scan_instruction_pattern, validate_trust_class, SourceTrustClass};
use crate::memory_candidate::{
    promote_candidate_to_memory_with_route, route_candidate, update_candidate_after_lifecycle,
    ParsedMemoryCandidate,
};

use super::{CandidateRow, ReviewMeta, ReviewPromotion};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PatternAcknowledgement {
    pattern_id: String,
    pattern_version: i64,
}

pub(super) fn approve_candidate_with_meta_and_ack(
    conn: &mut Connection,
    id: i64,
    meta: &ReviewMeta,
    acknowledged_pattern_id: Option<&str>,
) -> Result<Option<i64>> {
    let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
    let result = approve_candidate_in_transaction(&tx, id, meta, acknowledged_pattern_id)?;
    tx.commit()?;
    Ok(result)
}

pub(crate) fn approve_candidate_in_transaction(
    conn: &Connection,
    id: i64,
    meta: &ReviewMeta,
    acknowledged_pattern_id: Option<&str>,
) -> Result<Option<i64>> {
    let Some(row) = super::load_candidate(conn, id)? else {
        return Ok(None);
    };
    let acknowledgement = approval_acknowledgement(&row, acknowledged_pattern_id)?;
    let promotion = promote_row(conn, &row, "approved", None, meta, acknowledgement.as_ref())?;
    Ok(Some(promotion.memory_id))
}

pub(crate) fn edit_candidate_in_transaction(
    conn: &Connection,
    id: i64,
    edit: super::CandidateEdit,
    meta: &ReviewMeta,
) -> Result<Option<i64>> {
    let edit = normalize_candidate_edit(edit)?;
    let Some(row) = super::load_candidate(conn, id)? else {
        return Ok(None);
    };
    super::ensure_reviewable(&row)?;
    let edited = row.apply_edit(edit)?;
    if let Some(matched) = scan_instruction_pattern(&edited.text) {
        bail!(
            "edited candidate {} matched instruction-pattern {}@v{}; review and acknowledge the pattern before promotion",
            row.id,
            matched.pattern_id,
            matched.pattern_set_version
        );
    }
    let promotion = promote_row(conn, &row, "edited", Some(&edited), meta, None)?;
    Ok(Some(promotion.memory_id))
}

pub(crate) fn normalize_candidate_edit(
    mut edit: super::CandidateEdit,
) -> Result<super::CandidateEdit> {
    if edit.scope.is_none()
        && edit.memory_type.is_none()
        && edit.topic_key.is_none()
        && edit.text.is_none()
    {
        bail!("edit requires at least one changed field");
    }
    edit.scope = edit
        .scope
        .as_deref()
        .map(crate::memory_candidate::normalize_scope)
        .transpose()?;
    edit.memory_type = edit
        .memory_type
        .as_deref()
        .map(crate::memory_candidate::normalize_memory_type)
        .transpose()?;
    edit.topic_key = edit
        .topic_key
        .as_deref()
        .map(crate::memory_candidate::normalize_topic_key)
        .transpose()?;
    if let Some(text) = edit.text.take() {
        let text = text.trim().to_string();
        if text.is_empty() {
            bail!("edit text must not be empty");
        }
        edit.text = Some(text);
    }
    Ok(edit)
}

fn approval_acknowledgement(
    row: &CandidateRow,
    acknowledged_pattern_id: Option<&str>,
) -> Result<Option<PatternAcknowledgement>> {
    match row.review_status.as_str() {
        "pending_review" => {
            if acknowledged_pattern_id.is_some() {
                bail!(
                    "candidate {} is pending_review; acknowledge-pattern is only valid for quarantined candidates",
                    row.id
                );
            }
            Ok(None)
        }
        "quarantined" => {
            let expected_pattern = row
                .quarantine_pattern_id
                .as_deref()
                .context("quarantined candidate is missing quarantine_pattern_id")?;
            let expected_version = row
                .quarantine_pattern_version
                .context("quarantined candidate is missing quarantine_pattern_version")?;
            let Some(acknowledged_pattern_id) = acknowledged_pattern_id
                .map(str::trim)
                .filter(|value| !value.is_empty())
            else {
                bail!(
                    "candidate {} is quarantined by pattern {}; pass --acknowledge-pattern {} to approve after review",
                    row.id,
                    expected_pattern,
                    expected_pattern
                );
            };
            if acknowledged_pattern_id != expected_pattern {
                bail!(
                    "candidate {} acknowledged pattern {} does not match quarantine pattern {}",
                    row.id,
                    acknowledged_pattern_id,
                    expected_pattern
                );
            }
            Ok(Some(PatternAcknowledgement {
                pattern_id: expected_pattern.to_string(),
                pattern_version: expected_version,
            }))
        }
        _ => {
            super::ensure_pending(row)?;
            Ok(None)
        }
    }
}

pub(super) fn promote_row(
    conn: &Connection,
    row: &CandidateRow,
    review_status: &str,
    edited: Option<&ParsedMemoryCandidate>,
    meta: &ReviewMeta,
    acknowledgement: Option<&PatternAcknowledgement>,
) -> Result<ReviewPromotion> {
    let project = row
        .source_project
        .as_deref()
        .or(row.project.as_deref())
        .context("candidate is missing source project path")?;
    let candidate = edited.cloned().unwrap_or_else(|| row.as_candidate());
    let mut route = if edited.is_some() {
        route_candidate(project, None, &candidate, std::iter::empty())
    } else {
        row.route_for(&candidate)
    };
    if edited.is_some() && row.source_kind.as_deref() == Some("pack") {
        let pack_route = row.route_for(&candidate);
        route.topic_domain = pack_route.topic_domain;
        route.routing_reason = pack_route.routing_reason;
    }
    let outcome = promote_candidate_to_memory_with_route(
        conn,
        None,
        project,
        row.id,
        &candidate,
        &row.evidence_event_ids,
        &route,
        parse_row_trust(row)?,
    )?;
    let status = outcome.review_status_for(review_status);
    let now = chrono::Utc::now().timestamp();
    update_candidate_after_lifecycle(conn, row.id, &candidate, &route, status)?;
    conn.execute(
        "UPDATE memory_candidates
         SET updated_at_epoch = ?1, review_actor = ?2, reviewed_at_epoch = ?1,
             review_action_source = ?3, review_batch_id = ?4, review_reason = ?5
         WHERE id = ?6",
        params![
            now,
            meta.actor,
            meta.action_source.as_str(),
            meta.batch_id,
            meta.reason,
            row.id
        ],
    )?;
    let memory_id = outcome
        .memory_id
        .context("candidate promotion produced no memory id")?;
    if let Some(acknowledgement) = acknowledgement {
        conn.execute(
            "UPDATE memory_candidates
             SET acknowledged_pattern_id = ?1, acknowledged_pattern_version = ?2,
                 acknowledged_at_epoch = ?3, updated_at_epoch = ?3
             WHERE id = ?4",
            params![
                acknowledgement.pattern_id.as_str(),
                acknowledgement.pattern_version,
                now,
                row.id
            ],
        )?;
        conn.execute(
            "UPDATE memories
             SET acknowledged_pattern_id = ?1, acknowledged_pattern_version = ?2,
                 acknowledged_at_epoch = ?3
             WHERE id = ?4",
            params![
                acknowledgement.pattern_id.as_str(),
                acknowledgement.pattern_version,
                now,
                memory_id
            ],
        )?;
    }
    Ok(ReviewPromotion {
        memory_id,
        promoted: outcome.promoted,
    })
}

fn parse_row_trust(row: &CandidateRow) -> Result<SourceTrustClass> {
    validate_trust_class(&row.source_trust_class)?;
    Ok(SourceTrustClass::parse(&row.source_trust_class)
        .unwrap_or(SourceTrustClass::LocalToolOutput))
}