remem-ai 0.6.35

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use axum::{
    extract::{Query, State},
    http::StatusCode,
    response::IntoResponse,
    Json,
};
use rusqlite::types::ToSql;

use super::super::helpers::{error_response, open_request_db};
use super::super::types::{
    BlockedParams, BlockedReasonItem, CandidateItem, CandidateParams, DbState, ListMeta,
    ListResponse,
};

const SECS_PER_DAY: i64 = 86_400;

pub(in crate::api) async fn handle_blocked_candidates(
    State(_state): State<DbState>,
    Query(params): Query<BlockedParams>,
) -> impl IntoResponse {
    let conn = match open_request_db() {
        Ok(conn) => conn,
        Err(response) => return response,
    };
    let project = params.project.as_deref().filter(|s| !s.is_empty());
    let reasons = match crate::memory_candidate::review_stats::query_block_reasons(&conn, project) {
        Ok(reasons) => reasons,
        Err(err) => {
            return error_response(
                StatusCode::INTERNAL_SERVER_ERROR,
                "blocked_failed",
                &err.to_string(),
            )
            .into_response()
        }
    };
    let items: Vec<BlockedReasonItem> = reasons
        .into_iter()
        .map(|reason| BlockedReasonItem {
            reason: reason.reason,
            pending: reason.pending,
            example_ids: reason.example_ids,
        })
        .collect();
    let count = items.len();
    Json(ListResponse {
        data: items,
        meta: ListMeta {
            count,
            total: count as i64,
            limit: count as i64,
            offset: 0,
        },
    })
    .into_response()
}

pub(in crate::api) async fn handle_list_candidates(
    State(_state): State<DbState>,
    Query(params): Query<CandidateParams>,
) -> impl IntoResponse {
    let conn = match open_request_db() {
        Ok(conn) => conn,
        Err(response) => return response,
    };

    let status = params
        .status
        .as_deref()
        .filter(|s| !s.is_empty())
        .unwrap_or("pending_review");
    let limit = params.limit.unwrap_or(50).clamp(1, 100);
    let offset = params.offset.unwrap_or(0).max(0);
    let mut conditions = vec!["c.review_status = ?1".to_string()];
    let mut binds: Vec<Box<dyn ToSql>> = vec![Box::new(status.to_string())];
    let mut idx = 2usize;
    if let Some(project) = params.project.as_deref().filter(|s| !s.is_empty()) {
        push_candidate_project_filter(project, &mut idx, &mut conditions, &mut binds);
    }
    if let Some(memory_type) = params.memory_type.as_deref().filter(|s| !s.is_empty()) {
        conditions.push(format!("c.memory_type = ?{idx}"));
        binds.push(Box::new(memory_type.to_string()));
        idx += 1;
    }
    if let Some(block_reason) = params.block_reason.as_deref().filter(|s| !s.is_empty()) {
        conditions.push(format!("c.auto_promote_block_reason = ?{idx}"));
        binds.push(Box::new(block_reason.to_string()));
        idx += 1;
    }
    if let Some(topic_key) = params.topic_key.as_deref().filter(|s| !s.is_empty()) {
        conditions.push(format!("c.topic_key = ?{idx}"));
        binds.push(Box::new(topic_key.to_string()));
        idx += 1;
    }
    if let Some(contains) = params.contains.as_deref().filter(|s| !s.is_empty()) {
        let pattern = like_pattern(contains);
        conditions.push(format!(
            "(c.text LIKE ?{idx} ESCAPE '\\' OR c.topic_key LIKE ?{} ESCAPE '\\')",
            idx + 1
        ));
        binds.push(Box::new(pattern.clone()));
        binds.push(Box::new(pattern));
        idx += 2;
    }
    if let Some(min_confidence) = params.min_confidence {
        if !(0.0..=1.0).contains(&min_confidence) {
            return error_response(
                StatusCode::BAD_REQUEST,
                "candidate_filter_invalid",
                "min_confidence must be between 0 and 1",
            )
            .into_response();
        }
        conditions.push(format!("c.confidence >= ?{idx}"));
        binds.push(Box::new(min_confidence));
        idx += 1;
    }
    if let Some(older_than_days) = params.older_than_days {
        let cutoff = match older_than_cutoff(chrono::Utc::now().timestamp(), older_than_days) {
            Ok(cutoff) => cutoff,
            Err(message) => {
                return error_response(StatusCode::BAD_REQUEST, "candidate_filter_invalid", message)
                    .into_response()
            }
        };
        conditions.push(format!("c.created_at_epoch <= ?{idx}"));
        binds.push(Box::new(cutoff));
        idx += 1;
    }
    let where_sql = conditions.join(" AND ");

    let count_sql = format!(
        "SELECT COUNT(*) FROM memory_candidates c \
         LEFT JOIN projects p ON p.id = c.project_id \
         WHERE {where_sql}"
    );
    let binds_refs = crate::db::to_sql_refs(&binds);
    let total: i64 = match conn.query_row(&count_sql, binds_refs.as_slice(), |row| row.get(0)) {
        Ok(n) => n,
        Err(err) => {
            return error_response(
                StatusCode::INTERNAL_SERVER_ERROR,
                "count_failed",
                &err.to_string(),
            )
            .into_response()
        }
    };

    let result = (|| -> anyhow::Result<Vec<CandidateItem>> {
        let sql = format!(
            "SELECT c.id, c.memory_type, c.text, c.scope, c.confidence, c.risk_class, \
                    c.review_status, c.evidence_event_ids, c.created_at_epoch, \
                    COALESCE(c.target_project, p.project_path, c.source_project, \
                             CASE WHEN c.owner_scope = 'repo' THEN c.owner_key END) AS project \
             FROM memory_candidates c \
             LEFT JOIN projects p ON p.id = c.project_id \
             WHERE {where_sql} ORDER BY c.created_at_epoch DESC LIMIT ?{idx} OFFSET ?{}",
            idx + 1,
        );
        binds.push(Box::new(limit));
        binds.push(Box::new(offset));
        let mut stmt = conn.prepare(&sql)?;
        let binds_refs2 = crate::db::to_sql_refs(&binds);
        let rows = stmt.query_map(binds_refs2.as_slice(), |row| {
            let evidence_json: Option<String> = row.get(7)?;
            let evidence_count = evidence_json
                .as_deref()
                .and_then(|s| serde_json::from_str::<Vec<serde_json::Value>>(s).ok())
                .map(|v| v.len() as i64)
                .unwrap_or(0);
            Ok(CandidateItem {
                id: row.get(0)?,
                project: row.get(9)?,
                memory_type: row.get(1)?,
                text: row.get(2)?,
                scope: row.get(3)?,
                confidence: row.get(4)?,
                risk_class: row.get(5)?,
                review_status: row.get(6)?,
                evidence_count,
                created_at_epoch: row.get(8)?,
            })
        })?;
        rows.collect::<Result<Vec<_>, rusqlite::Error>>()
            .map_err(anyhow::Error::from)
    })();

    let items = match result {
        Ok(v) => v,
        Err(err) => {
            return error_response(
                StatusCode::INTERNAL_SERVER_ERROR,
                "list_failed",
                &err.to_string(),
            )
            .into_response()
        }
    };

    let count = items.len();
    Json(ListResponse {
        data: items,
        meta: ListMeta {
            count,
            total,
            limit,
            offset,
        },
    })
    .into_response()
}

fn like_pattern(query: &str) -> String {
    let mut pattern = String::with_capacity(query.len() + 2);
    pattern.push('%');
    for ch in query.chars() {
        if matches!(ch, '%' | '_' | '\\') {
            pattern.push('\\');
        }
        pattern.push(ch);
    }
    pattern.push('%');
    pattern
}

fn older_than_cutoff(now_epoch: i64, older_than_days: i64) -> Result<i64, &'static str> {
    if older_than_days < 0 {
        return Err("older_than_days must be non-negative");
    }
    let age_secs = older_than_days
        .checked_mul(SECS_PER_DAY)
        .ok_or("older_than_days is too large")?;
    now_epoch
        .checked_sub(age_secs)
        .ok_or("older_than_days is too large")
}

fn push_candidate_project_filter(
    project: &str,
    idx: &mut usize,
    conditions: &mut Vec<String>,
    binds: &mut Vec<Box<dyn ToSql>>,
) {
    let project_idx = *idx;
    binds.push(Box::new(project.to_string()));
    *idx += 1;
    let source_idx = *idx;
    binds.push(Box::new(project.to_string()));
    *idx += 1;
    let target_idx = *idx;
    binds.push(Box::new(project.to_string()));
    *idx += 1;
    let owner_idx = *idx;
    binds.push(Box::new(project.to_string()));
    *idx += 1;
    conditions.push(format!(
        "(p.project_path = ?{project_idx} \
          OR c.source_project = ?{source_idx} \
          OR c.target_project = ?{target_idx} \
          OR (c.owner_scope = 'repo' AND c.owner_key = ?{owner_idx}))"
    ));
}