remem-ai 0.6.49

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

use crate::db;

pub(in crate::cli) fn run_context_gate_status(
    project: Option<&str>,
    session: Option<&str>,
    limit: i64,
    json: bool,
) -> Result<()> {
    let conn = open_context_gate_db_read_only()?;
    let project = project.map(db::project_from_cwd);
    let limit = limit.clamp(1, 200);
    let rows = load_recent_context_gate_rows(&conn, project.as_deref(), session, limit)?;
    let report = ContextGateStatusReport {
        database: db::db_path().display().to_string(),
        filters: ContextGateStatusFilters {
            project,
            session: session.map(str::to_string),
            limit,
        },
        rows,
    };

    if json {
        println!("{}", serde_json::to_string_pretty(&report)?);
    } else {
        print_context_gate_status(&report);
    }
    Ok(())
}

fn open_context_gate_db_read_only() -> Result<Connection> {
    let db_path = db::db_path();
    db::open_db_read_only()
        .with_context(|| format!("open read-only remem database {}", db_path.display()))
}

fn load_recent_context_gate_rows(
    conn: &Connection,
    project: Option<&str>,
    session: Option<&str>,
    limit: i64,
) -> Result<Vec<ContextGateStatusRow>> {
    if !context_injections_table_exists(conn)? {
        return Ok(Vec::new());
    }

    let data_version_select = if context_injections_column_exists(conn, "data_version")? {
        "data_version"
    } else {
        "NULL"
    };
    let sql = format!(
        "SELECT host, project, injection_key, session_id, hook_source, output_mode,
                output_chars, updated_at_epoch, last_emitted_epoch, emit_count, suppress_count,
                {data_version_select}
         FROM context_injections
         WHERE (?1 IS NULL OR project = ?1)
           AND (?2 IS NULL OR session_id = ?2)
         ORDER BY updated_at_epoch DESC
         LIMIT ?3",
    );
    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt
        .query_map(params![project, session, limit], |row| {
            let mut status = ContextGateStatusRow {
                host: row.get(0)?,
                project: row.get(1)?,
                injection_key: row.get(2)?,
                session_id: row.get(3)?,
                hook_source: row.get(4)?,
                output_mode: row.get(5)?,
                output_chars: row.get(6)?,
                updated_at_epoch: row.get(7)?,
                updated_at: String::new(),
                last_emitted_epoch: row.get(8)?,
                last_emitted_at: String::new(),
                emit_count: row.get(9)?,
                suppress_count: row.get(10)?,
                data_version: row.get(11)?,
                inferred_reason: String::new(),
            };
            status.updated_at = format_context_gate_timestamp(status.updated_at_epoch);
            status.last_emitted_at = format_context_gate_timestamp(status.last_emitted_epoch);
            status.inferred_reason = infer_context_gate_reason(&status).to_string();
            Ok(status)
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(rows)
}

fn context_injections_table_exists(conn: &Connection) -> Result<bool> {
    context_injections_column_exists(conn, "*")
}

fn context_injections_column_exists(conn: &Connection, column_name: &str) -> Result<bool> {
    if column_name == "*" {
        let exists = conn.query_row(
            "SELECT EXISTS (
                 SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'context_injections'
             )",
            [],
            |row| row.get::<_, i64>(0),
        )?;
        return Ok(exists != 0);
    }
    let exists = conn.query_row(
        "SELECT EXISTS (
             SELECT 1 FROM pragma_table_info('context_injections') WHERE name = ?1
         )",
        [column_name],
        |row| row.get::<_, i64>(0),
    )?;
    Ok(exists != 0)
}

fn print_context_gate_status(report: &ContextGateStatusReport) {
    println!("Recent context injections:");
    if report.rows.is_empty() {
        println!("  (none)");
        return;
    }

    for row in &report.rows {
        println!(
            "  {} {} {} source={} inferred_reason={} project={} session={} emits={} suppressions={}",
            row.updated_at,
            row.host,
            row.output_mode,
            row.hook_source.as_deref().unwrap_or("-"),
            row.inferred_reason,
            row.project,
            row.session_id.as_deref().unwrap_or("-"),
            row.emit_count,
            row.suppress_count
        );
    }
}

fn infer_context_gate_reason(row: &ContextGateStatusRow) -> &'static str {
    match row.output_mode.as_str() {
        "delta" => "changed_hash",
        "suppressed" if source_is_default_suppressed(row.hook_source.as_deref()) => {
            "suppressed_source"
        }
        "suppressed" if row.data_version.is_some() => "suppressed_data_version",
        "suppressed" => "same_hash_or_strict",
        "full" if source_requires_restart(row.hook_source.as_deref()) => "restart_source",
        "full" if row.emit_count <= 1 => "first_or_forced",
        "full" => "repeat_full_or_forced",
        _ => "unknown",
    }
}

fn source_is_default_suppressed(source: Option<&str>) -> bool {
    matches!(
        source.map(|value| value.trim().to_ascii_lowercase()),
        Some(value) if value == "compact"
    )
}

fn source_requires_restart(source: Option<&str>) -> bool {
    matches!(
        source.map(|value| value.trim().to_ascii_lowercase()),
        Some(value) if value == "clear"
    )
}

fn format_context_gate_timestamp(epoch: i64) -> String {
    chrono::DateTime::from_timestamp(epoch, 0)
        .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
        .unwrap_or_default()
}

#[derive(Debug, Serialize)]
struct ContextGateStatusReport {
    database: String,
    filters: ContextGateStatusFilters,
    rows: Vec<ContextGateStatusRow>,
}

#[derive(Debug, Serialize)]
struct ContextGateStatusFilters {
    project: Option<String>,
    session: Option<String>,
    limit: i64,
}

#[derive(Debug, Serialize)]
struct ContextGateStatusRow {
    host: String,
    project: String,
    injection_key: String,
    session_id: Option<String>,
    hook_source: Option<String>,
    output_mode: String,
    output_chars: i64,
    updated_at_epoch: i64,
    updated_at: String,
    last_emitted_epoch: i64,
    last_emitted_at: String,
    emit_count: i64,
    suppress_count: i64,
    data_version: Option<String>,
    inferred_reason: String,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn setup_context_gate_conn() -> Connection {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(include_str!(
            "../../migrations/v016_context_injection_gate.sql"
        ))
        .unwrap();
        conn
    }

    #[test]
    fn context_gate_status_reads_recent_rows() -> Result<()> {
        let conn = setup_context_gate_conn();
        conn.execute(
            "INSERT INTO context_injections
             (host, project, injection_key, session_id, transcript_path, hook_source,
              context_hash, output_mode, output_chars, created_at_epoch, updated_at_epoch,
              last_emitted_epoch, emit_count, suppress_count)
             VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
            params![
                "codex-cli",
                "/tmp/remem",
                "session:/tmp/remem:sess-1",
                "sess-1",
                "compact",
                "hash-a",
                "suppressed",
                0,
                100,
                110,
                100,
                1,
                2,
            ],
        )?;

        let rows = load_recent_context_gate_rows(&conn, Some("/tmp/remem"), Some("sess-1"), 20)?;

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].host, "codex-cli");
        assert_eq!(rows[0].hook_source.as_deref(), Some("compact"));
        assert_eq!(rows[0].output_mode, "suppressed");
        assert_eq!(rows[0].inferred_reason, "suppressed_source");
        assert_eq!(rows[0].emit_count, 1);
        assert_eq!(rows[0].suppress_count, 2);
        Ok(())
    }

    #[test]
    fn context_gate_status_infers_clear_before_first_emit() {
        let row = ContextGateStatusRow {
            host: "codex-cli".to_string(),
            project: "/tmp/remem".to_string(),
            injection_key: "session:/tmp/remem:sess-1".to_string(),
            session_id: Some("sess-1".to_string()),
            hook_source: Some("clear".to_string()),
            output_mode: "full".to_string(),
            output_chars: 200,
            updated_at_epoch: 100,
            updated_at: String::new(),
            last_emitted_epoch: 100,
            last_emitted_at: String::new(),
            emit_count: 1,
            suppress_count: 0,
            data_version: None,
            inferred_reason: String::new(),
        };

        assert_eq!(infer_context_gate_reason(&row), "restart_source");
    }

    #[test]
    fn context_gate_status_json_uses_inferred_reason_field() -> Result<()> {
        let report = ContextGateStatusReport {
            database: "/tmp/remem/database".to_string(),
            filters: ContextGateStatusFilters {
                project: None,
                session: None,
                limit: 20,
            },
            rows: vec![ContextGateStatusRow {
                host: "codex-cli".to_string(),
                project: "/tmp/remem".to_string(),
                injection_key: "session:/tmp/remem:sess-1".to_string(),
                session_id: Some("sess-1".to_string()),
                hook_source: Some("compact".to_string()),
                output_mode: "suppressed".to_string(),
                output_chars: 0,
                updated_at_epoch: 100,
                updated_at: "1970-01-01 00:01:40 UTC".to_string(),
                last_emitted_epoch: 100,
                last_emitted_at: "1970-01-01 00:01:40 UTC".to_string(),
                emit_count: 1,
                suppress_count: 2,
                data_version: None,
                inferred_reason: "suppressed_source".to_string(),
            }],
        };

        let value = serde_json::to_value(report)?;

        assert_eq!(value["rows"][0]["inferred_reason"], "suppressed_source");
        assert!(value["rows"][0].get("reason").is_none());
        Ok(())
    }

    #[test]
    fn context_gate_status_missing_table_returns_no_rows() -> Result<()> {
        let conn = Connection::open_in_memory()?;

        let rows = load_recent_context_gate_rows(&conn, None, None, 20)?;

        assert!(rows.is_empty());
        Ok(())
    }

    #[test]
    fn context_gate_status_infers_data_version_suppression() -> Result<()> {
        let conn = setup_context_gate_conn();
        conn.execute(
            "ALTER TABLE context_injections ADD COLUMN data_version TEXT",
            [],
        )?;
        conn.execute(
            "INSERT INTO context_injections
             (host, project, injection_key, session_id, transcript_path, hook_source,
              context_hash, data_version, output_mode, output_chars, created_at_epoch,
              updated_at_epoch, last_emitted_epoch, emit_count, suppress_count)
             VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
            params![
                "codex-cli",
                "/tmp/remem",
                "session:/tmp/remem:sess-1",
                "sess-1",
                "SessionStart",
                "hash-a",
                "version-a",
                "suppressed",
                0,
                100,
                110,
                100,
                1,
                2,
            ],
        )?;

        let rows = load_recent_context_gate_rows(&conn, Some("/tmp/remem"), Some("sess-1"), 20)?;

        assert_eq!(rows[0].data_version.as_deref(), Some("version-a"));
        assert_eq!(rows[0].inferred_reason, "suppressed_data_version");
        Ok(())
    }

    #[test]
    fn context_gate_status_refuses_plaintext_without_explicit_override() -> Result<()> {
        let test_dir =
            crate::db::test_support::ScopedTestDataDir::new("context-gate-status-fail-closed");
        let conn = crate::db::open_db()?;
        drop(conn);
        std::env::remove_var(db::ALLOW_PLAINTEXT_ENV);

        let err = open_context_gate_db_read_only()
            .expect_err("status must fail closed without a cipher key or plaintext override");
        let message = format!("{err:#}");

        assert!(test_dir.db_path().exists());
        assert!(message.contains("SQLCipher key"), "got: {message}");
        assert!(
            message.contains(db::ALLOW_PLAINTEXT_ENV),
            "override must be explicit: {message}"
        );
        Ok(())
    }
}