remem-ai 0.6.79

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{tool, tool_router};

use super::super::types::{ListRawSessionsParams, SearchRawParams};
use super::errors::{self, McpToolError, McpToolResult};
use super::MemoryServer;
use crate::memory::raw_archive;
use crate::memory::raw_query::{
    build_raw_search_json, parse_time_lower_bound, parse_time_upper_bound,
};

#[tool_router(router = tool_router_raw, vis = "pub(super)")]
impl MemoryServer {
    #[tool(
        description = "Search the raw archive (every user/assistant turn captured by the Stop hook). \
        Use this when `search` returns no curated match or you need to recall a literal phrase from past chats. \
        Returns the untreated conversation content — expect noise. \
        The raw archive is what guarantees 'what was said remains searchable' even when summarize/promote skip a turn."
    )]
    pub(super) fn search_raw(
        &self,
        Parameters(params): Parameters<SearchRawParams>,
    ) -> McpToolResult<String> {
        const TOOL: &str = "search_raw";
        let start = std::time::Instant::now();
        let normalized_limit = params.limit.unwrap_or(20).max(1);
        let normalized_offset = params.offset.unwrap_or(0).max(0);
        crate::log::info(
            "mcp",
            &format!(
                "search_raw called query={:?} project={:?} branch={:?} role={:?} limit={} offset={}",
                params.query,
                params.project,
                params.branch,
                params.role,
                normalized_limit,
                normalized_offset,
            ),
        );
        let since_epoch = parse_optional_bound(
            TOOL,
            "since",
            params.since.as_deref(),
            parse_time_lower_bound,
        )?;
        let until_epoch = parse_optional_bound(
            TOOL,
            "until",
            params.until.as_deref(),
            parse_time_upper_bound,
        )?;
        self.with_conn(TOOL, |conn| {
            let req = raw_archive::RawSearchRequest {
                query: params.query.clone(),
                project: params.project.clone(),
                branch: params.branch.clone(),
                role: params.role.clone(),
                limit: normalized_limit.saturating_add(1),
                offset: normalized_offset,
                since_epoch,
                until_epoch,
            };
            let mut hits = raw_archive::search_raw_messages(conn, &req).map_err(|e| {
                crate::log::warn("mcp", &format!("search_raw failed: {}", e));
                McpToolError::db_query(TOOL, e)
            })?;
            let has_more = hits.len() as i64 > normalized_limit;
            hits.truncate(normalized_limit as usize);

            crate::log::info(
                "mcp",
                &format!(
                    "search_raw done count={} {}ms",
                    hits.len(),
                    start.elapsed().as_millis()
                ),
            );
            let output = build_raw_search_json(
                &params.query,
                params.project.as_deref(),
                params.branch.as_deref(),
                params.role.as_deref(),
                normalized_limit,
                normalized_offset,
                since_epoch,
                until_epoch,
                has_more,
                &hits,
            );
            errors::to_json_pretty(TOOL, &output)
        })
    }

    #[tool(
        description = "List sessions with raw archive messages inside a time window, grouped by \
        (source_root, project, session_id) with first/last message epoch, message count, and optional \
        role=user message samples. Use for recap-style summaries of what happened in a period. \
        Output fields match `remem raw sessions --json`."
    )]
    pub(super) fn list_raw_sessions(
        &self,
        Parameters(params): Parameters<ListRawSessionsParams>,
    ) -> McpToolResult<String> {
        const TOOL: &str = "list_raw_sessions";
        let start = std::time::Instant::now();
        crate::log::info(
            "mcp",
            &format!(
                "list_raw_sessions called since={:?} until={:?} project={:?} sample={}",
                params.since,
                params.until,
                params.project,
                params.sample.unwrap_or(0),
            ),
        );
        let since_epoch = parse_optional_bound(
            TOOL,
            "since",
            params.since.as_deref(),
            parse_time_lower_bound,
        )?;
        let until_epoch = parse_optional_bound(
            TOOL,
            "until",
            params.until.as_deref(),
            parse_time_upper_bound,
        )?;
        self.with_conn(TOOL, |conn| {
            let query = raw_archive::RawSessionQuery {
                since_epoch,
                until_epoch,
                project: params.project.clone(),
                sample_user_messages: params.sample.unwrap_or(0).max(0),
            };
            let sessions = raw_archive::list_sessions(conn, &query).map_err(|e| {
                crate::log::warn("mcp", &format!("list_raw_sessions failed: {}", e));
                McpToolError::db_query(TOOL, e)
            })?;

            crate::log::info(
                "mcp",
                &format!(
                    "list_raw_sessions done count={} {}ms",
                    sessions.len(),
                    start.elapsed().as_millis()
                ),
            );
            errors::to_json_pretty(TOOL, &raw_archive::build_sessions_json(&query, sessions))
        })
    }
}

fn parse_optional_bound(
    tool: &'static str,
    field: &str,
    value: Option<&str>,
    parser: fn(&str) -> anyhow::Result<i64>,
) -> Result<Option<i64>, McpToolError> {
    value.map(parser).transpose().map_err(|e| {
        crate::log::warn("mcp", &format!("{tool} invalid {field}: {e}"));
        McpToolError::invalid_request(tool, e.to_string())
    })
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeSet;

    use rmcp::handler::server::wrapper::Parameters;
    use serde_json::Value;

    use super::*;
    use crate::db::test_support::ScopedTestDataDir;
    use crate::memory::raw_archive::{
        insert_raw_message, insert_raw_message_from_root_at, ROLE_USER, SOURCE_HOOK,
        SOURCE_ROOT_LOCAL,
    };

    #[test]
    fn search_raw_returns_the_cli_json_envelope() -> anyhow::Result<()> {
        let _dir = ScopedTestDataDir::new("mcp-raw-search-envelope");
        let conn = crate::db::open_db()?;
        for content in ["first literal needle", "second literal needle"] {
            insert_raw_message(
                &conn,
                "session-raw",
                "/repo",
                ROLE_USER,
                content,
                SOURCE_HOOK,
                Some("main"),
                Some("/repo"),
            )?;
        }
        drop(conn);

        let server = MemoryServer::new()?;
        let response = server
            .search_raw(Parameters(SearchRawParams {
                query: "literal needle".to_string(),
                project: Some("/repo".to_string()),
                branch: Some("main".to_string()),
                role: Some(ROLE_USER.to_string()),
                limit: Some(1),
                offset: Some(0),
                since: Some("0".to_string()),
                until: Some("9999999999".to_string()),
            }))
            .map_err(anyhow::Error::msg)?;
        let json: Value = serde_json::from_str(&response)?;

        let keys = json
            .as_object()
            .expect("raw search response must be an object")
            .keys()
            .map(String::as_str)
            .collect::<BTreeSet<_>>();
        assert_eq!(
            keys,
            BTreeSet::from([
                "branch",
                "count",
                "has_more",
                "limit",
                "next_offset",
                "note",
                "offset",
                "project",
                "query",
                "results",
                "role",
                "since_epoch",
                "source_type",
                "until_epoch",
            ])
        );
        assert_eq!(json["query"], "literal needle");
        assert_eq!(json["limit"], 1);
        assert_eq!(json["offset"], 0);
        assert_eq!(json["count"], 1);
        assert_eq!(json["has_more"], true);
        assert_eq!(json["next_offset"], 1);
        assert_eq!(json["source_type"], "raw_archive");
        assert!(json["results"][0]["content"]
            .as_str()
            .is_some_and(|content| content.contains("literal needle")));
        assert_eq!(json["results"][0]["cwd"], "/repo");
        assert!(json["results"][0].get("preview").is_none());
        Ok(())
    }

    #[test]
    fn search_raw_date_only_until_includes_messages_later_that_utc_day() -> anyhow::Result<()> {
        let _dir = ScopedTestDataDir::new("mcp-raw-search-date-until");
        let conn = crate::db::open_db()?;
        insert_raw_message_from_root_at(
            &conn,
            "session-date-bound",
            "/repo",
            ROLE_USER,
            "midday boundary needle",
            SOURCE_HOOK,
            Some("main"),
            Some("/repo"),
            SOURCE_ROOT_LOCAL,
            Some(1_767_355_200),
        )?;
        drop(conn);

        let server = MemoryServer::new()?;
        let response = server
            .search_raw(Parameters(SearchRawParams {
                query: "boundary needle".to_string(),
                project: Some("/repo".to_string()),
                branch: Some("main".to_string()),
                role: Some(ROLE_USER.to_string()),
                limit: Some(20),
                offset: Some(0),
                since: Some("2026-01-02".to_string()),
                until: Some("2026-01-02".to_string()),
            }))
            .map_err(anyhow::Error::msg)?;
        let json: Value = serde_json::from_str(&response)?;

        assert_eq!(json["since_epoch"], 1_767_312_000_i64);
        assert_eq!(json["until_epoch"], 1_767_398_399_i64);
        assert_eq!(json["count"], 1);
        assert_eq!(json["results"][0]["content"], "midday boundary needle");
        Ok(())
    }
}