remem-ai 0.6.24

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

use crate::memory::service;

use super::super::helpers::{error_response, open_request_db};
use super::super::types::{
    DbState, LocalCopyResponse, SaveMemoryNextStepResponse, SaveMemoryRequest, SaveMemoryResponse,
};

pub(in crate::api) async fn handle_save_memory(
    State(_state): State<DbState>,
    Json(req): Json<SaveMemoryRequest>,
) -> impl IntoResponse {
    let conn = match open_request_db() {
        Ok(conn) => conn,
        Err(response) => return response,
    };

    let reference_time_epoch = req.reference_time_epoch;
    let save_req = service::SaveMemoryRequest {
        text: req.text,
        title: req.title,
        project: req.project,
        session_id: req.session_id,
        host: req.host.or_else(|| Some("api".to_string())),
        topic_key: req.topic_key,
        memory_type: req.memory_type,
        files: req.files,
        scope: req.scope,
        created_at_epoch: req.created_at_epoch,
        branch: req.branch,
        local_path: req.local_path,
        local_copy_enabled: req.local_copy_enabled,
        claim_enabled: req.claim_enabled,
        claim_source: req.claim_source.or_else(|| Some("api_save".to_string())),
        acknowledge_pattern: req.acknowledge_pattern,
    };

    match service::save_memory_with_reference_time(&conn, &save_req, reference_time_epoch) {
        Ok(saved) => (
            StatusCode::CREATED,
            Json(SaveMemoryResponse {
                id: saved.id,
                status: saved.status,
                memory_type: saved.memory_type,
                project: saved.project,
                scope: saved.scope,
                topic_key: saved.topic_key,
                branch: saved.branch,
                operation: saved.operation,
                created_at_epoch: saved.created_at_epoch,
                reference_time_epoch: saved.reference_time_epoch,
                updated_at_epoch: saved.updated_at_epoch,
                upserted: saved.upserted,
                local_copy: LocalCopyResponse {
                    status: saved.local_copy.status,
                    path: saved.local_copy.path,
                    reason: saved.local_copy.reason,
                },
                local_status: saved.local_status,
                local_path: saved.local_path,
                claim_status: saved.claim_status,
                claim_id: saved.claim_id,
                claim_error: saved.claim_error,
                next_step: SaveMemoryNextStepResponse {
                    tool: saved.next_step.tool,
                    ids: saved.next_step.ids,
                    source: saved.next_step.source,
                    reason: saved.next_step.reason,
                },
            }),
        )
            .into_response(),
        Err(err) => map_save_memory_error(err).into_response(),
    }
}

fn map_save_memory_error(err: anyhow::Error) -> impl IntoResponse {
    let msg = err.to_string();
    if err.is::<service::SaveMemoryValidationError>() {
        return error_response(StatusCode::BAD_REQUEST, "save_validation_failed", &msg);
    }
    if err.is::<service::LocalCopyError>() {
        return error_response(StatusCode::BAD_REQUEST, "save_local_copy_failed", &msg);
    }
    error_response(StatusCode::INTERNAL_SERVER_ERROR, "save_failed", &msg)
}

#[cfg(test)]
mod tests {
    use axum::{body::to_bytes, extract::State, http::StatusCode, response::IntoResponse, Json};
    use serde_json::Value;

    use crate::api::types::SaveMemoryRequest;
    use crate::db::test_support::ScopedTestDataDir;

    use super::{handle_save_memory, DbState};

    fn request_with_invalid_shape(
        memory_type: Option<&str>,
        scope: Option<&str>,
    ) -> SaveMemoryRequest {
        SaveMemoryRequest {
            text: "valid body".to_string(),
            title: Some("Invalid shape".to_string()),
            project: Some("proj".to_string()),
            session_id: None,
            host: None,
            topic_key: None,
            memory_type: memory_type.map(str::to_string),
            files: None,
            scope: scope.map(str::to_string),
            reference_time_epoch: None,
            created_at_epoch: None,
            branch: None,
            local_path: None,
            local_copy_enabled: Some(false),
            claim_enabled: None,
            claim_source: None,
            acknowledge_pattern: None,
        }
    }

    #[tokio::test]
    async fn save_memory_validation_errors_return_stable_bad_request() {
        let _dir = ScopedTestDataDir::new("api-save-validation");
        for req in [
            SaveMemoryRequest {
                text: "   ".to_string(),
                ..request_with_invalid_shape(Some("decision"), Some("project"))
            },
            request_with_invalid_shape(Some("decison"), Some("project")),
            request_with_invalid_shape(Some("decision"), Some("globla")),
        ] {
            let response = handle_save_memory(State(DbState), Json(req))
                .await
                .into_response();
            assert_eq!(response.status(), StatusCode::BAD_REQUEST);
            let body = match to_bytes(response.into_body(), usize::MAX).await {
                Ok(body) => body,
                Err(err) => panic!("response body should read: {err}"),
            };
            let payload: Value = match serde_json::from_slice(&body) {
                Ok(payload) => payload,
                Err(err) => panic!("save response should be valid json: {err}"),
            };
            assert_eq!(payload["error"]["code"], "save_validation_failed");
        }
    }
}