remem-ai 0.4.9

Persistent memory for Claude Code and Codex — single binary, automatic context
Documentation
use crate::memory::{
    raw_archive::RawMessage,
    service::{MultiHopMeta, SearchResultSet},
    Memory,
};
use crate::retrieval::search::{ChannelContribution, SearchExplain, SearchExplainResult};
use serde_json::Value;

use super::{
    search::{
        build_search_json, build_search_request, preview_raw_text, preview_text,
        render_search_results,
    },
    show::{format_memory_timestamp, ShowJson},
    why::{render_why_memory, ContextGateSummary},
};

fn sample_memory() -> Memory {
    Memory {
        id: 1,
        session_id: None,
        project: "proj".to_string(),
        topic_key: None,
        title: "Title".to_string(),
        text: format!("{}\nsecond line", "a".repeat(120)),
        memory_type: "decision".to_string(),
        files: None,
        created_at_epoch: 0,
        updated_at_epoch: 0,
        status: "active".to_string(),
        branch: None,
        scope: "project".to_string(),
    }
}

fn sample_raw() -> RawMessage {
    RawMessage {
        id: 9,
        session_id: "session-1".to_string(),
        project: "proj".to_string(),
        role: "user".to_string(),
        content: format!("{}\nsecond line", "r".repeat(120)),
        source: "hook".to_string(),
        branch: Some("main".to_string()),
        cwd: None,
        created_at_epoch: 0,
    }
}

fn sample_explain() -> SearchExplain {
    SearchExplain {
        query: "needle".to_string(),
        project: Some("proj".to_string()),
        memory_type: Some("decision".to_string()),
        branch: Some("main".to_string()),
        include_stale: false,
        limit: 10,
        offset: 0,
        fetch_limit: 33,
        expanded_terms: vec!["needle".to_string()],
        core_terms: vec!["needle".to_string()],
        fts_query: Some("\"needle\"".to_string()),
        temporal_range: None,
        rrf_k: 60.0,
        channels: vec![crate::retrieval::search::SearchExplainChannel {
            name: "fts".to_string(),
            hits: vec![crate::retrieval::search::ChannelHit {
                memory_id: 1,
                rank: 1,
            }],
        }],
        results: vec![SearchExplainResult {
            memory_id: 1,
            final_rank: 1,
            final_score: 0.016393,
            project: "proj".to_string(),
            scope: "project".to_string(),
            visibility: "project-local".to_string(),
            contributions: vec![ChannelContribution {
                channel: "fts".to_string(),
                rank: 1,
                score: 0.016393,
            }],
        }],
        has_more: false,
        raw_fallback_count: 0,
    }
}

#[test]
fn cli_query_preview_uses_first_line_and_truncates() {
    let memory = sample_memory();

    let preview = preview_text(&memory);
    assert_eq!(preview.len(), 80);
    assert!(preview.chars().all(|ch| ch == 'a'));
}

#[test]
fn cli_query_format_memory_timestamp_handles_invalid_epoch() {
    assert_eq!(format_memory_timestamp(i64::MAX), "");
}

#[test]
fn cli_search_request_carries_service_search_options() {
    let request = build_search_request(
        "needle",
        Some("/repo"),
        Some("decision"),
        5,
        10,
        Some("feature"),
        true,
        true,
        true,
    );

    assert_eq!(request.query.as_deref(), Some("needle"));
    assert_eq!(request.project.as_deref(), Some("/repo"));
    assert_eq!(request.memory_type.as_deref(), Some("decision"));
    assert_eq!(request.limit, 5);
    assert_eq!(request.offset, 10);
    assert_eq!(request.branch.as_deref(), Some("feature"));
    assert!(request.include_stale);
    assert!(request.multi_hop);
    assert!(request.explain);
}

#[test]
fn cli_search_render_shows_multi_hop_has_more_and_raw_fallback() {
    let result = SearchResultSet {
        memories: vec![sample_memory()],
        multi_hop: Some(MultiHopMeta {
            hops: 2,
            entities_discovered: vec!["Graphiti".to_string(), "Mem0".to_string()],
        }),
        has_more: true,
        explain: None,
        raw_hits: vec![sample_raw()],
    };

    let output = render_search_results(&result, 10, 5);

    assert!(output.contains("Multi-hop: hops=2 entities=Graphiti, Mem0"));
    assert!(output.contains("Found 1 result(s):"));
    assert!(output.contains("remem show 1"));
    assert!(output.contains("remem why 1"));
    assert!(output.contains("remem search \"<query>\" --offset 15"));
    assert!(output.contains("Raw archive fallback:"));
    assert!(output.contains("[raw:9] user | proj | 1970-01-01 | branch=main"));
    assert!(output.contains(
        "use raw hits for recall only; promote durable conclusions with review/save_memory."
    ));
}

#[test]
fn cli_search_render_uses_raw_fallback_when_curated_is_empty() {
    let result = SearchResultSet {
        memories: vec![],
        multi_hop: None,
        has_more: false,
        explain: None,
        raw_hits: vec![sample_raw()],
    };

    let output = render_search_results(&result, 0, 10);

    assert!(output.contains("No curated memories found."));
    assert!(output.contains("Raw archive fallback:"));
    assert!(output.contains("use raw hits for recall only"));
}

#[test]
fn cli_search_render_includes_explain_without_memory_content_dump() {
    let result = SearchResultSet {
        memories: vec![sample_memory()],
        multi_hop: None,
        has_more: false,
        explain: Some(sample_explain()),
        raw_hits: vec![],
    };

    let output = render_search_results(&result, 0, 10);

    assert!(output.contains("Search explain:"));
    assert!(output.contains("channels:"));
    assert!(output.contains("fts: 1#1"));
    assert!(output.contains("visibility=project-local"));
    assert!(output.contains("contributions: fts#1=0.016393"));
    assert!(!output.contains("second line"));
}

#[test]
fn cli_search_render_includes_explain_for_empty_results() {
    let result = SearchResultSet {
        memories: vec![],
        multi_hop: None,
        has_more: false,
        explain: Some(sample_explain()),
        raw_hits: vec![],
    };

    let output = render_search_results(&result, 0, 10);

    assert!(output.contains("No curated memories found."));
    assert!(output.contains("remem search \"<query>\" --include-stale"));
    assert!(output.contains("remem search \"<query>\" --multi-hop"));
    assert!(output.contains("remem search \"<query>\" --project /path/to/repo"));
    assert!(output.contains("Search explain:"));
    assert!(output.contains("fts_query: Some(\"\\\"needle\\\"\")"));
}

#[test]
fn cli_query_raw_preview_uses_first_line_and_truncates() {
    let raw = sample_raw();

    let preview = preview_raw_text(&raw);

    assert_eq!(preview.len(), 100);
    assert!(preview.chars().all(|ch| ch == 'r'));
}

#[test]
fn cli_search_json_report_is_machine_parseable() -> std::result::Result<(), serde_json::Error> {
    let result = SearchResultSet {
        memories: vec![sample_memory()],
        multi_hop: Some(MultiHopMeta {
            hops: 1,
            entities_discovered: vec!["Mem0".to_string()],
        }),
        has_more: true,
        explain: Some(sample_explain()),
        raw_hits: vec![sample_raw()],
    };
    let output = build_search_json(
        "needle",
        Some("proj"),
        Some("decision"),
        3,
        6,
        Some("main"),
        true,
        true,
        true,
        &result,
    );

    let text = serde_json::to_string(&output)?;
    let parsed: Value = serde_json::from_str(&text)?;

    assert_eq!(parsed["query"], "needle");
    assert_eq!(parsed["limit"], 3);
    assert_eq!(parsed["next_offset"], 9);
    assert_eq!(parsed["results"][0]["id"], 1);
    assert_eq!(parsed["raw_hits"][0]["id"], 9);
    assert_eq!(parsed["multi_hop"]["entities_discovered"][0], "Mem0");
    assert_eq!(parsed["explain_details"]["query"], "needle");
    Ok(())
}

#[test]
fn cli_show_json_report_is_machine_parseable() -> std::result::Result<(), serde_json::Error> {
    let output = ShowJson {
        found: true,
        id: 1,
        memory: Some(sample_memory()),
    };

    let text = serde_json::to_string(&output)?;
    let parsed: Value = serde_json::from_str(&text)?;

    assert_eq!(parsed["found"], true);
    assert_eq!(parsed["id"], 1);
    assert_eq!(parsed["memory"]["title"], "Title");
    Ok(())
}

#[test]
fn cli_why_render_distinguishes_visibility_from_query_scoring() {
    let gate = ContextGateSummary {
        host: "codex-cli".to_string(),
        project: "proj".to_string(),
        output_mode: "suppressed".to_string(),
        emit_count: 2,
        suppress_count: 1,
        updated_at_epoch: 0,
        last_emitted_epoch: 0,
    };

    let output = render_why_memory(&sample_memory(), Some("proj"), Some("main"), Some(&gate));

    assert!(output.contains("Memory #1"));
    assert!(output.contains("project match: exact proj"));
    assert!(output.contains("branch match: branchless; visible in branch-scoped search for main"));
    assert!(output.contains("type: core memory type"));
    assert!(output.contains("status: active; default search can include it"));
    assert!(output.contains("query scoring: query-specific"));
    assert!(output.contains("context visibility: memory index candidate and core candidate"));
    assert!(output.contains("context gate: latest codex-cli output for proj: mode=suppressed"));
    assert!(output.contains("gate rows are context-output level, not per-memory proof"));
    assert!(output.contains("remem show 1"));
}