remem-ai 0.4.9

Persistent memory for Claude Code and Codex — single binary, automatic context
Documentation
use anyhow::Result;
use rusqlite::{params, Connection};

use crate::db::{self, record_captured_event, CaptureEventInput, ExtractionTaskKind};

use super::{process_with_generator, MemoryCandidateResult};

fn setup_conn() -> Connection {
    let conn = Connection::open_in_memory().expect("in-memory db should open");
    crate::migrate::run_migrations(&conn).expect("migrations should run");
    conn
}

fn setup_task(conn: &mut Connection, session_id: &str) -> Result<db::ExtractionTask> {
    record_captured_event(
        conn,
        &CaptureEventInput {
            host: "codex-cli",
            session_id,
            project: "/tmp/remem",
            cwd: None,
            event_type: "tool_result",
            role: None,
            tool_name: Some("Bash"),
            content: "cargo test passed",
            task_kind: Some(ExtractionTaskKind::MemoryCandidate),
        },
    )?;
    db::claim_next_extraction_task(conn, "worker-a", 60)?
        .ok_or_else(|| anyhow::anyhow!("expected memory candidate task"))
}

fn insert_source_observation(
    conn: &Connection,
    task: &db::ExtractionTask,
    text: &str,
) -> Result<()> {
    insert_source_observation_with_confidence(conn, task, text, 0.91)
}

fn insert_source_observation_with_confidence(
    conn: &Connection,
    task: &db::ExtractionTask,
    text: &str,
    confidence: f64,
) -> Result<()> {
    let obs_id = db::insert_observation_with_branch(
        conn,
        "capture-observation-test",
        &task.project,
        "decision",
        Some("Worker loop decision"),
        None,
        Some(text),
        None,
        None,
        None,
        None,
        None,
        12,
        None,
        None,
    )?;
    let event_id = task.high_watermark_event_id.unwrap_or(1);
    conn.execute(
        "UPDATE observations
         SET host_id = ?1,
             project_id = ?2,
             session_row_id = ?3,
             observation_type = 'decision',
             text = ?4,
             evidence_event_ids = ?5,
             confidence = ?6
         WHERE id = ?7",
        params![
            task.host_id,
            task.project_id,
            task.session_row_id,
            text,
            serde_json::to_string(&vec![event_id])?,
            confidence,
            obs_id
        ],
    )?;
    Ok(())
}

#[tokio::test]
async fn memory_candidate_auto_promotes_default_confidence_observation() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-default-confidence")?;
    insert_source_observation_with_confidence(
        &conn,
        &task,
        "Use the worker loop to process extraction tasks after observation extraction.",
        0.75,
    )?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok(low_risk_candidate_xml())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Written {
            candidates: 1,
            promoted: 1,
            pending_review: 0
        }
    );
    let review_status: String =
        conn.query_row("SELECT review_status FROM memory_candidates", [], |row| {
            row.get(0)
        })?;
    assert_eq!(review_status, "auto_promoted");
    Ok(())
}

fn low_risk_candidate_xml() -> String {
    "<memory_candidate>\
        <scope>project</scope>\
        <type>decision</type>\
        <topic_key>decision-worker-loop</topic_key>\
        <risk_class>low</risk_class>\
        <confidence>0.92</confidence>\
        <text>Use the worker loop to process extraction tasks after observation extraction.</text>\
     </memory_candidate>"
        .to_string()
}

#[tokio::test]
async fn memory_candidate_auto_promotes_low_risk_project_candidate() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-auto")?;
    insert_source_observation(
        &conn,
        &task,
        "Use the worker loop to process extraction tasks after observation extraction.",
    )?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok(low_risk_candidate_xml())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Written {
            candidates: 1,
            promoted: 1,
            pending_review: 0
        }
    );
    let (candidate_id, review_status): (i64, String) = conn.query_row(
        "SELECT id, review_status FROM memory_candidates",
        [],
        |row| Ok((row.get(0)?, row.get(1)?)),
    )?;
    assert_eq!(review_status, "auto_promoted");
    let (memory_type, topic_key, evidence, source_candidate_id, confidence): (
        String,
        String,
        String,
        i64,
        f64,
    ) = conn.query_row(
        "SELECT memory_type, topic_key, evidence_event_ids, source_candidate_id, confidence
         FROM memories WHERE source_candidate_id = ?1",
        params![candidate_id],
        |row| {
            Ok((
                row.get(0)?,
                row.get(1)?,
                row.get(2)?,
                row.get(3)?,
                row.get(4)?,
            ))
        },
    )?;
    assert_eq!(memory_type, "decision");
    assert_eq!(topic_key, "decision-worker-loop");
    assert_eq!(source_candidate_id, candidate_id);
    assert!(evidence.contains('1'));
    assert_eq!(confidence, 0.92);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_keeps_self_classified_unsupported_candidate_pending() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-unsupported")?;
    insert_source_observation(
        &conn,
        &task,
        "Use deterministic review gates for candidates.",
    )?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("<memory_candidate><scope>project</scope><type>decision</type><topic_key>unsupported-auto</topic_key><risk_class>low</risk_class><confidence>0.99</confidence><text>The production deploy succeeded and should be recorded.</text></memory_candidate>".to_string())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Written {
            candidates: 1,
            promoted: 0,
            pending_review: 1
        }
    );
    let review_status: String =
        conn.query_row("SELECT review_status FROM memory_candidates", [], |row| {
            row.get(0)
        })?;
    let memory_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
    assert_eq!(review_status, "pending_review");
    assert_eq!(memory_count, 0);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_accepts_procedure_but_keeps_it_pending_review() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-procedure")?;
    insert_source_observation(
        &conn,
        &task,
        "Run cargo test after memory type registry changes.",
    )?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("<memory_candidate><scope>project</scope><type>procedure</type><topic_key>procedure-cargo-test</topic_key><risk_class>low</risk_class><confidence>0.95</confidence><text>Run cargo test after memory type registry changes.</text></memory_candidate>".to_string())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Written {
            candidates: 1,
            promoted: 0,
            pending_review: 1
        }
    );
    let (memory_type, review_status): (String, String) = conn.query_row(
        "SELECT memory_type, review_status FROM memory_candidates",
        [],
        |row| Ok((row.get(0)?, row.get(1)?)),
    )?;
    let memory_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
    assert_eq!(memory_type, "procedure");
    assert_eq!(review_status, "pending_review");
    assert_eq!(memory_count, 0);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_leaves_high_risk_candidate_pending_review() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-pending")?;
    insert_source_observation(&conn, &task, "User prefers global editor behavior.")?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("<memory_candidate><scope>global</scope><type>preference</type><topic_key>global-editor</topic_key><risk_class>high</risk_class><confidence>0.95</confidence><text>User prefers global editor behavior.</text></memory_candidate>".to_string())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Written {
            candidates: 1,
            promoted: 0,
            pending_review: 1
        }
    );
    let review_status: String =
        conn.query_row("SELECT review_status FROM memory_candidates", [], |row| {
            row.get(0)
        })?;
    let memory_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
    assert_eq!(review_status, "pending_review");
    assert_eq!(memory_count, 0);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_duplicate_output_is_idempotent() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-dup")?;
    insert_source_observation(
        &conn,
        &task,
        "Use the worker loop to process extraction tasks after observation extraction.",
    )?;

    process_with_generator(&mut conn, &task, |_prompt| async {
        Ok(low_risk_candidate_xml())
    })
    .await?;
    let second = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok(low_risk_candidate_xml())
    })
    .await?;

    assert_eq!(
        second,
        MemoryCandidateResult::Written {
            candidates: 0,
            promoted: 0,
            pending_review: 0
        }
    );
    let candidate_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memory_candidates", [], |row| {
            row.get(0)
        })?;
    let memory_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
    assert_eq!(candidate_count, 1);
    assert_eq!(memory_count, 1);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_accepts_explicit_no_candidates() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-none")?;
    insert_source_observation(&conn, &task, "Low signal output.")?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("<no_candidates reason=\"low signal\"/>".to_string())
    })
    .await?;

    assert_eq!(result, MemoryCandidateResult::NoCandidates);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_defer_output_is_explicit() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-defer")?;
    insert_source_observation(&conn, &task, "Deploy target is staging or production.")?;

    let result = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("<defer reason=\"ambiguous conflict\"/>".to_string())
    })
    .await?;

    assert_eq!(
        result,
        MemoryCandidateResult::Deferred {
            reason: "ambiguous conflict".to_string()
        }
    );
    let candidate_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memory_candidates", [], |row| {
            row.get(0)
        })?;
    let memory_count: i64 =
        conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
    assert_eq!(candidate_count, 0);
    assert_eq!(memory_count, 0);
    Ok(())
}

#[tokio::test]
async fn memory_candidate_malformed_output_fails_closed() -> Result<()> {
    let mut conn = setup_conn();
    let task = setup_task(&mut conn, "sess-candidate-bad")?;
    insert_source_observation(&conn, &task, "Important durable decision.")?;

    let err = process_with_generator(&mut conn, &task, |_prompt| async {
        Ok("not xml".to_string())
    })
    .await
    .expect_err("malformed output should fail");

    assert!(err.to_string().contains("malformed memory_candidate"));
    Ok(())
}