remem-ai 0.6.70

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::io::{Read, Write};
#[cfg(feature = "local-onnx")]
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use rusqlite::Connection;

use super::{search_with_branch_explain, SearchExplain, SearchExplainChannel};

const ENV_KEYS: &[&str] = &[
    "REMEM_CONFIG",
    "REMEM_EMBEDDINGS_PROVIDER",
    "REMEM_EMBEDDING_PROVIDER",
    "REMEM_EMBEDDINGS_FALLBACK",
    "REMEM_EMBEDDINGS_API_KEY",
    "REMEM_EMBEDDING_API_KEY",
    "REMEM_EMBEDDINGS_BASE_URL",
    "REMEM_EMBEDDING_BASE_URL",
    "REMEM_EMBEDDINGS_MODEL",
    "REMEM_EMBEDDING_MODEL",
    "REMEM_EMBEDDINGS_DIMENSIONS",
    "REMEM_EMBEDDING_DIMENSIONS",
    "REMEM_EMBEDDINGS_MODEL_DIR",
    "HF_HOME",
    "HF_ENDPOINT",
    "OPENAI_API_KEY",
];

struct ScopedSearchEmbeddingEnv {
    _guard: crate::runtime_config::TestEnvGuard,
    saved: Vec<(&'static str, Option<String>)>,
}

impl ScopedSearchEmbeddingEnv {
    fn clean() -> Self {
        let guard = crate::runtime_config::TEST_ENV_LOCK
            .lock()
            .expect("env lock should acquire");
        let saved = ENV_KEYS
            .iter()
            .map(|key| (*key, std::env::var(key).ok()))
            .collect::<Vec<_>>();
        for key in ENV_KEYS {
            unsafe { std::env::remove_var(key) };
        }
        let isolated_config = std::env::temp_dir().join(format!(
            "remem-search-embedding-missing-config-{}-{}.toml",
            std::process::id(),
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
        ));
        unsafe { std::env::set_var("REMEM_CONFIG", isolated_config) };
        Self {
            _guard: guard,
            saved,
        }
    }
}

impl Drop for ScopedSearchEmbeddingEnv {
    fn drop(&mut self) {
        for (key, value) in self.saved.drain(..) {
            match value {
                Some(value) => unsafe { std::env::set_var(key, value) },
                None => unsafe { std::env::remove_var(key) },
            }
        }
    }
}

fn with_clean_search_embedding_env<T>(f: impl FnOnce() -> T) -> T {
    let _env = ScopedSearchEmbeddingEnv::clean();
    f()
}

fn setup_search_conn() -> Result<Connection> {
    let conn = Connection::open_in_memory()?;
    crate::memory::tests_helper::setup_memory_schema(&conn);
    Ok(conn)
}

fn insert_search_memory(conn: &Connection) -> Result<()> {
    conn.execute(
        "INSERT INTO memories
         (id, session_id, project, title, content, memory_type,
          created_at_epoch, updated_at_epoch, status, scope)
         VALUES (1, 'session-1', '/repo', 'Semantic fallback',
                 'FTS result survives provider failure.', 'decision',
                 1, 1, 'active', 'project')",
        [],
    )?;
    Ok(())
}

fn explain_for_query(conn: &Connection, query: &str) -> Result<SearchExplain> {
    let (_memories, explain) =
        search_with_branch_explain(conn, Some(query), Some("/repo"), None, 5, 0, false, None)?;
    explain.context("query explain should be present")
}

fn vector_channel(explain: &SearchExplain) -> Result<&SearchExplainChannel> {
    explain
        .channels
        .iter()
        .find(|channel| channel.name == "vector")
        .context("vector channel should be reported")
}

#[cfg(feature = "local-onnx")]
struct TestModelRoot(PathBuf);

#[cfg(feature = "local-onnx")]
impl TestModelRoot {
    fn new() -> Self {
        Self(std::env::temp_dir().join(format!(
            "remem-search-embedding-model-test-{}-{}",
            std::process::id(),
            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
        )))
    }

    fn path(&self) -> &Path {
        &self.0
    }
}

#[cfg(feature = "local-onnx")]
impl Drop for TestModelRoot {
    fn drop(&mut self) {
        if let Err(error) = std::fs::remove_dir_all(&self.0) {
            if error.kind() != std::io::ErrorKind::NotFound {
                eprintln!(
                    "failed to remove search embedding test model root {}: {error}",
                    self.0.display()
                );
            }
        }
    }
}

#[test]
fn search_explain_reports_actual_api_embedding_profile() -> Result<()> {
    with_clean_search_embedding_env(|| -> Result<()> {
        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
        let addr = listener.local_addr()?;
        let handle = std::thread::spawn(move || -> Result<()> {
            let (mut stream, _) = listener.accept()?;
            let mut buffer = [0_u8; 8192];
            let _ = stream.read(&mut buffer)?;
            let body = r#"{"data":[{"embedding":[0.1,0.2,0.3]}],"model":"actual-api-profile"}"#;
            let response = format!(
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            stream.write_all(response.as_bytes())?;
            Ok(())
        });
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "api");
            std::env::set_var("REMEM_EMBEDDINGS_MODEL", "configured-api-profile");
            std::env::set_var("REMEM_EMBEDDINGS_API_KEY", "test-key");
            std::env::set_var("REMEM_EMBEDDINGS_BASE_URL", format!("http://{addr}/v1"));
        }
        let conn = setup_search_conn()?;
        insert_search_memory(&conn)?;

        let explain = explain_for_query(&conn, "Semantic fallback")?;
        handle
            .join()
            .map_err(|_| anyhow::anyhow!("embedding test server thread panicked"))??;
        let vector = vector_channel(&explain)?;
        let embedding = vector
            .embedding
            .as_ref()
            .context("vector channel should expose embedding execution")?;

        assert_eq!(embedding.configured_provider, "api");
        assert_eq!(embedding.active_provider, "api");
        assert_eq!(embedding.model, "actual-api-profile");
        assert_eq!(embedding.dimensions, 3);
        assert!(!embedding.degraded);
        assert!(embedding.degradation_reason.is_none());

        let serialized = serde_json::to_value(&explain)?;
        let fts = serialized["channels"]
            .as_array()
            .and_then(|channels| {
                channels
                    .iter()
                    .find(|channel| channel["name"].as_str() == Some("fts"))
            })
            .context("fts channel should be serialized")?;
        assert!(
            fts.get("embedding").is_none(),
            "non-vector channels must remain compatible: {fts}"
        );
        Ok(())
    })
}

#[test]
#[cfg(all(unix, feature = "local-onnx"))]
fn search_explain_reports_initial_auto_feature_hash_degradation() -> Result<()> {
    use std::os::unix::fs::symlink;

    with_clean_search_embedding_env(|| -> Result<()> {
        let model_root = TestModelRoot::new();
        std::fs::create_dir_all(model_root.path())?;
        symlink(
            model_root.path().join("missing-install-target"),
            model_root
                .path()
                .join(crate::retrieval::embedding::TEST_LOCAL_SEMANTIC_MODEL),
        )?;
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "auto");
            std::env::set_var("REMEM_EMBEDDINGS_MODEL_DIR", model_root.path());
        }
        let conn = setup_search_conn()?;

        let explain = explain_for_query(&conn, "initial degraded vector query")?;
        let embedding = vector_channel(&explain)?
            .embedding
            .as_ref()
            .context("degraded vector channel should expose embedding execution")?;

        assert_eq!(embedding.configured_provider, "auto");
        assert_eq!(embedding.active_provider, "feature-hash");
        assert_eq!(
            embedding.model,
            crate::retrieval::embedding::FEATURE_HASH_EMBEDDING_MODEL
        );
        assert_eq!(
            embedding.dimensions,
            crate::retrieval::embedding::FEATURE_HASH_EMBEDDING_DIMENSIONS
        );
        assert!(embedding.degraded);
        assert!(
            embedding
                .degradation_reason
                .as_deref()
                .unwrap_or_default()
                .contains("symlink"),
            "{embedding:#?}"
        );
        Ok(())
    })
}

#[test]
#[cfg(feature = "local-onnx")]
fn search_explain_reports_runtime_local_unavailable_feature_hash_fallback() -> Result<()> {
    with_clean_search_embedding_env(|| -> Result<()> {
        let model_root = TestModelRoot::new();
        crate::retrieval::embedding::install_test_local_embedding_model(model_root.path())?;
        let _failure = crate::retrieval::embedding::fail_next_test_model_embed_unavailable(
            model_root.path(),
            "synthetic local runtime loss token=runtime-secret-value",
        )?;
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "auto");
            std::env::set_var("REMEM_EMBEDDINGS_MODEL_DIR", model_root.path());
        }
        let conn = setup_search_conn()?;

        let explain = explain_for_query(&conn, "runtime embedding race")?;
        let embedding = vector_channel(&explain)?
            .embedding
            .as_ref()
            .context("runtime fallback should expose embedding execution")?;
        let reason = embedding.degradation_reason.as_deref().unwrap_or_default();

        assert_eq!(embedding.configured_provider, "auto");
        assert_eq!(embedding.active_provider, "feature-hash");
        assert_eq!(
            embedding.model,
            crate::retrieval::embedding::FEATURE_HASH_EMBEDDING_MODEL
        );
        assert_eq!(
            embedding.dimensions,
            crate::retrieval::embedding::FEATURE_HASH_EMBEDDING_DIMENSIONS
        );
        assert!(embedding.degraded);
        assert!(
            reason.contains("automatic local embedding provider became unavailable"),
            "{reason}"
        );
        assert!(reason.contains("[REDACTED]"), "{reason}");
        assert!(!reason.contains("runtime-secret-value"), "{reason}");
        Ok(())
    })
}

#[test]
#[cfg(feature = "local-onnx")]
fn search_explain_keeps_generic_local_inference_errors_loud() -> Result<()> {
    with_clean_search_embedding_env(|| -> Result<()> {
        let model_root = TestModelRoot::new();
        crate::retrieval::embedding::install_test_local_embedding_model(model_root.path())?;
        let _failure = crate::retrieval::embedding::fail_next_test_model_embed_generic(
            model_root.path(),
            "synthetic generic local inference failure",
        )?;
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "auto");
            std::env::set_var("REMEM_EMBEDDINGS_MODEL_DIR", model_root.path());
        }
        let conn = setup_search_conn()?;

        let error = explain_for_query(&conn, "generic inference race")
            .expect_err("generic inference errors must not degrade to feature-hash");
        let message = format!("{error:#}");

        assert!(
            message.contains("synthetic generic local inference failure"),
            "{message}"
        );
        assert!(!message.contains("using feature-hash"), "{message}");
        Ok(())
    })
}

#[test]
fn search_returns_provider_error_when_api_failure_falls_back_to_off() -> Result<()> {
    with_clean_search_embedding_env(|| -> Result<()> {
        let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
        let addr = listener.local_addr()?;
        let handle = std::thread::spawn(move || -> Result<()> {
            let (mut stream, _) = listener.accept()?;
            let mut buffer = [0_u8; 8192];
            let _ = stream.read(&mut buffer)?;
            let body = "provider unavailable";
            let response = format!(
                "HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\n\r\n{}",
                body.len(),
                body
            );
            stream.write_all(response.as_bytes())?;
            Ok(())
        });
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "api");
            std::env::set_var("REMEM_EMBEDDINGS_FALLBACK", "off");
            std::env::set_var("REMEM_EMBEDDINGS_API_KEY", "test-key");
            std::env::set_var("REMEM_EMBEDDINGS_BASE_URL", format!("http://{addr}/v1"));
        }
        let conn = setup_search_conn()?;
        insert_search_memory(&conn)?;

        let error = search_with_branch_explain(
            &conn,
            Some("Semantic fallback"),
            Some("/repo"),
            None,
            5,
            0,
            false,
            None,
        )
        .expect_err("fallback=off after an API failure must not skip vector search errors");
        handle
            .join()
            .map_err(|_| anyhow::anyhow!("embedding test server thread panicked"))??;
        let error = format!("{error:#}");
        assert!(error.contains("provider unavailable"));
        assert!(error.contains("fallback off disabled provider fallback"));
        Ok(())
    })
}

#[test]
fn search_returns_provider_error_when_fallback_off_provider_is_unavailable_before_call(
) -> Result<()> {
    with_clean_search_embedding_env(|| -> Result<()> {
        unsafe {
            std::env::set_var("REMEM_EMBEDDINGS_PROVIDER", "api");
            std::env::set_var("REMEM_EMBEDDINGS_FALLBACK", "off");
        }
        let conn = setup_search_conn()?;
        insert_search_memory(&conn)?;

        let error = search_with_branch_explain(
            &conn,
            Some("Semantic fallback"),
            Some("/repo"),
            None,
            5,
            0,
            false,
            None,
        )
        .expect_err("fallback=off provider status failures must not disable vector search");
        let error = format!("{error:#}");
        assert!(error.contains("requires REMEM_EMBEDDINGS_API_KEY"));
        assert!(error.contains("fallback off disabled provider fallback"));
        Ok(())
    })
}