remem-ai 0.5.211

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use anyhow::Result;
use serde::Serialize;

use crate::{
    cli::query_types::UserReviewAction,
    db,
    user_context::{candidates, claims},
};

pub(in crate::cli) fn run_user_review(action: UserReviewAction) -> Result<()> {
    let conn = db::open_db()?;
    match action {
        UserReviewAction::Inbox {
            include_resolved,
            status,
            limit,
            json,
        } => {
            let candidates = candidates::list_candidates(
                &conn,
                &candidates::CandidateListRequest {
                    review_status: status.as_deref(),
                    include_resolved,
                    limit,
                },
            )?;
            if json {
                print_json(&CandidateListOutput {
                    count: candidates.len(),
                    candidates,
                })?;
            } else if candidates.is_empty() {
                println!("No user-context candidates found.");
            } else {
                for candidate in candidates {
                    print_candidate_summary(&candidate);
                }
            }
        }
        UserReviewAction::Approve { id, json } => {
            let result = candidates::approve_candidate(&conn, id)?;
            print_apply_result("approved", result, json)?;
        }
        UserReviewAction::Edit {
            id,
            text,
            claim_type,
            claim_key,
            sensitivity,
            note,
            json,
        } => {
            let result = candidates::edit_candidate(
                &conn,
                id,
                &candidates::CandidateEditRequest {
                    text: &text,
                    claim_type: claim_type.map(Into::into),
                    claim_key: claim_key.as_deref(),
                    sensitivity: sensitivity.map(Into::into),
                    review_note: note.as_deref(),
                },
            )?;
            print_apply_result("edited", result, json)?;
        }
        UserReviewAction::Reject { id, note, json } => {
            let candidate = candidates::reject_candidate(&conn, id, note.as_deref())?;
            print_status("rejected", candidate, json)?;
        }
        UserReviewAction::Suppress { id, note, json } => {
            let candidate = candidates::suppress_candidate(&conn, id, note.as_deref())?;
            print_status("suppressed", candidate, json)?;
        }
    }
    Ok(())
}

fn print_candidate_summary(candidate: &candidates::UserContextCandidate) {
    println!(
        "{} [{}:{} risk={} sensitivity={} confidence={:.3}] {}",
        candidate.id,
        candidate.claim_type,
        candidate.review_status,
        candidate.risk_class,
        candidate.sensitivity,
        candidate.confidence,
        candidate.claim_text
    );
    if let Some(reason) = candidate.auto_promote_block_reason.as_deref() {
        println!("  block_reason: {reason}");
    }
    if let Some(preview) = candidate.source_preview.as_deref() {
        println!("  source: {preview}");
    }
}

fn print_apply_result(
    status: &'static str,
    result: candidates::CandidateApplyResult,
    json: bool,
) -> Result<()> {
    if json {
        print_json(&CandidateApplyOutput {
            status,
            action: result.action,
            candidate: result.candidate,
            claim: result.claim,
        })?;
    } else if let Some(claim) = result.claim {
        println!(
            "User-context candidate {} is now {}; claim {} {}.",
            result.candidate.id, status, claim.id, result.action
        );
    } else {
        println!(
            "User-context candidate {} is now {}.",
            result.candidate.id, status
        );
    }
    Ok(())
}

fn print_status(
    status: &'static str,
    candidate: candidates::UserContextCandidate,
    json: bool,
) -> Result<()> {
    if json {
        print_json(&CandidateStatusOutput { status, candidate })?;
    } else {
        println!("User-context candidate {} is now {}.", candidate.id, status);
    }
    Ok(())
}

fn print_json<T: Serialize>(value: &T) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

#[derive(Serialize)]
struct CandidateListOutput {
    count: usize,
    candidates: Vec<candidates::UserContextCandidate>,
}

#[derive(Serialize)]
struct CandidateApplyOutput {
    status: &'static str,
    action: String,
    candidate: candidates::UserContextCandidate,
    claim: Option<claims::UserContextClaim>,
}

#[derive(Serialize)]
struct CandidateStatusOutput {
    status: &'static str,
    candidate: candidates::UserContextCandidate,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::query_types::UserReviewAction;
    use crate::user_context::candidates::{CandidateCreateRequest, UserContextCandidateRisk};
    use crate::user_context::claims::{UserContextClaimType, UserContextSensitivity};

    #[test]
    fn user_review_actions_apply_and_govern_candidates() -> Result<()> {
        let _dir = crate::db::test_support::ScopedTestDataDir::new("user-review-cli-actions");
        let conn = db::open_db()?;
        let approved = candidates::create_candidate(&conn, &candidate_request("Approve me"))?
            .candidate
            .id;
        let rejected = candidates::create_candidate(&conn, &candidate_request("Reject me"))?
            .candidate
            .id;
        let suppressed = candidates::create_candidate(&conn, &candidate_request("Suppress me"))?
            .candidate
            .id;
        drop(conn);

        run_user_review(UserReviewAction::Inbox {
            include_resolved: false,
            status: None,
            limit: 10,
            json: true,
        })?;
        run_user_review(UserReviewAction::Approve {
            id: approved,
            json: true,
        })?;
        run_user_review(UserReviewAction::Reject {
            id: rejected,
            note: Some("speculative".to_string()),
            json: true,
        })?;
        run_user_review(UserReviewAction::Suppress {
            id: suppressed,
            note: Some("not useful".to_string()),
            json: true,
        })?;

        let conn = db::open_db()?;
        assert_eq!(
            candidates::load_candidate(&conn, approved)?.review_status,
            "approved"
        );
        assert!(candidates::load_candidate(&conn, approved)?
            .result_claim_id
            .is_some());
        assert_eq!(
            candidates::load_candidate(&conn, rejected)?.review_status,
            "rejected"
        );
        assert_eq!(
            candidates::load_candidate(&conn, suppressed)?.review_status,
            "suppressed"
        );
        Ok(())
    }

    fn candidate_request(text: &str) -> CandidateCreateRequest<'_> {
        CandidateCreateRequest {
            text,
            owner_scope: None,
            owner_key: None,
            source_project: Some("/repo"),
            host: Some("codex-cli"),
            session_id: Some("session-1"),
            claim_type: UserContextClaimType::Preference,
            claim_key: Some("preference:cli-review-test"),
            confidence: 0.8,
            sensitivity: UserContextSensitivity::Normal,
            risk_class: UserContextCandidateRisk::Low,
            source_kind: "session_summary",
            source_refs_json: r#"[{"kind":"session_summary","id":1}]"#,
            source_preview: Some("candidate preview"),
            auto_promote: false,
            auto_promote_block_reason: None,
        }
    }
}