remem-ai 0.5.148

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use rusqlite::Connection;

const MAX_HINT_CHARS: usize = 180;
const MAX_CONTEXT_CHARS: usize = 4000;
const REBUILD_BATCH_SIZE: i64 = 500;

pub fn build_search_context(
    memory_type: &str,
    topic_key: Option<&str>,
    content: &str,
    files: Option<&str>,
) -> String {
    let mut hints = Vec::new();
    push_hint(&mut hints, format!("type: {memory_type}"));

    if let Some(topic_key) = topic_key.and_then(non_empty) {
        push_hint(
            &mut hints,
            format!("topic: {}", topic_key.replace(['-', '_'], " ")),
        );
    }

    let file_hints = parse_file_hints(files);
    if !file_hints.is_empty() {
        push_hint(&mut hints, format!("files: {}", file_hints.join(" ")));
    }

    for (label, snippet) in extract_labeled_hints(content) {
        push_hint(&mut hints, format!("{label}: {snippet}"));
    }

    let commands = extract_commands(content);
    if !commands.is_empty() {
        push_hint(&mut hints, format!("commands: {}", commands.join(" ; ")));
    }

    truncate_context(&hints.join("\n"))
}

pub fn rebuild_all(conn: &Connection) -> anyhow::Result<usize> {
    let mut changed = 0usize;
    let mut last_id = 0i64;

    loop {
        let rows = load_rebuild_batch(conn, last_id, REBUILD_BATCH_SIZE)?;
        let Some(next_last_id) = rows.last().map(|row| row.id) else {
            break;
        };

        for row in rows {
            let search_context = build_search_context(
                &row.memory_type,
                row.topic_key.as_deref(),
                &row.content,
                row.files.as_deref(),
            );
            changed += conn.execute(
                "UPDATE memories SET search_context = ?1 WHERE id = ?2",
                rusqlite::params![search_context, row.id],
            )?;
        }
        last_id = next_last_id;
    }

    Ok(changed)
}

struct RebuildRow {
    id: i64,
    topic_key: Option<String>,
    content: String,
    memory_type: String,
    files: Option<String>,
}

fn load_rebuild_batch(
    conn: &Connection,
    last_id: i64,
    batch_size: i64,
) -> anyhow::Result<Vec<RebuildRow>> {
    let mut stmt = conn.prepare(
        "SELECT id, topic_key, content, memory_type, files
         FROM memories
         WHERE id > ?1
         ORDER BY id
         LIMIT ?2",
    )?;
    let mapped = stmt.query_map(rusqlite::params![last_id, batch_size], |row| {
        Ok(RebuildRow {
            id: row.get(0)?,
            topic_key: row.get(1)?,
            content: row.get(2)?,
            memory_type: row.get(3)?,
            files: row.get(4)?,
        })
    })?;
    mapped.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}

fn non_empty(value: &str) -> Option<&str> {
    let trimmed = value.trim();
    (!trimmed.is_empty()).then_some(trimmed)
}

fn parse_file_hints(files: Option<&str>) -> Vec<String> {
    let Some(raw) = files.and_then(non_empty) else {
        return vec![];
    };

    let paths = serde_json::from_str::<Vec<String>>(raw).unwrap_or_else(|_| vec![raw.to_string()]);
    let mut hints = Vec::new();
    for path in paths {
        let Some(path) = non_empty(&path) else {
            continue;
        };
        push_hint(&mut hints, path.to_string());
        if let Some(basename) = path.rsplit('/').next().and_then(non_empty) {
            push_hint(&mut hints, basename.to_string());
        }
    }
    hints
}

fn extract_labeled_hints(content: &str) -> Vec<(&'static str, String)> {
    let cues = [
        ("symptom", &["symptom:", "issue:", "problem:", "error:"][..]),
        ("root cause", &["root cause:", "cause:"][..]),
        (
            "fix",
            &["fix:", "fixed:", "resolved by", "resolution:", "solution:"][..],
        ),
        (
            "verification",
            &["verification:", "verified", "tests:", "test:"][..],
        ),
        ("outcome", &["outcome:", "result:"][..]),
    ];

    let lower = content.to_lowercase();
    let mut found = Vec::new();
    for (label, variants) in cues {
        if let Some((position, cue)) = variants
            .iter()
            .filter_map(|cue| lower.find(cue).map(|pos| (pos, *cue)))
            .min_by_key(|(pos, _)| *pos)
        {
            let start = position + cue.len();
            let snippet = snippet_after(content, start);
            push_labeled_hint(&mut found, label, snippet);
        }
    }
    found
}

fn snippet_after(content: &str, start: usize) -> String {
    let snippet = content
        .get(start..)
        .unwrap_or("")
        .split(['\n', '.', ';'])
        .next()
        .unwrap_or("")
        .trim();
    if snippet.is_empty() {
        return String::new();
    }
    truncate_context(snippet)
        .chars()
        .take(MAX_HINT_CHARS)
        .collect::<String>()
}

fn push_labeled_hint(
    hints: &mut Vec<(&'static str, String)>,
    label: &'static str,
    snippet: String,
) {
    if hints.iter().any(|(existing, _)| *existing == label) {
        return;
    }
    hints.push((label, snippet));
}

fn extract_commands(content: &str) -> Vec<String> {
    let mut commands = Vec::new();
    let mut in_tick = false;
    let mut current = String::new();
    for ch in content.chars() {
        if ch == '`' {
            if in_tick {
                if looks_like_command(&current) {
                    push_hint(&mut commands, current.trim().to_string());
                }
                current.clear();
            }
            in_tick = !in_tick;
        } else if in_tick {
            current.push(ch);
        }
    }

    for line in content.lines() {
        let trimmed = line.trim();
        if let Some(command) = trimmed.strip_prefix("$ ").and_then(non_empty) {
            push_hint(&mut commands, command.to_string());
        }
    }
    commands
}

fn looks_like_command(value: &str) -> bool {
    let trimmed = value.trim();
    if trimmed.len() < 3 || trimmed.len() > MAX_HINT_CHARS {
        return false;
    }
    let Some(first) = trimmed.split_whitespace().next() else {
        return false;
    };
    matches!(
        first,
        "cargo"
            | "go"
            | "pytest"
            | "python"
            | "python3"
            | "node"
            | "npm"
            | "npx"
            | "pnpm"
            | "yarn"
            | "bun"
            | "deno"
            | "git"
            | "gh"
            | "uv"
            | "make"
            | "just"
            | "sqlite3"
            | "remem"
    )
}

fn push_hint(hints: &mut Vec<String>, value: String) {
    let normalized = value.trim();
    if normalized.is_empty() || hints.iter().any(|hint| hint == normalized) {
        return;
    }
    hints.push(normalized.to_string());
}

fn truncate_context(value: &str) -> String {
    if value.len() <= MAX_CONTEXT_CHARS {
        return value.to_string();
    }
    crate::db::truncate_str(value, MAX_CONTEXT_CHARS).to_string()
}

#[cfg(test)]
mod tests {
    use rusqlite::Connection;

    use super::{build_search_context, rebuild_all, REBUILD_BATCH_SIZE};

    #[test]
    fn search_context_includes_rebuildable_structured_hints() {
        let context = build_search_context(
            "bugfix",
            Some("cache-key-timeout"),
            "Issue: requests timed out. Cause: cache key drift. Resolved by invalidating \
             stale entries. Verified with `cargo test retrieval::memory_search`.",
            Some(r#"["src/retrieval/memory_search/fts.rs"]"#),
        );

        assert!(context.contains("type: bugfix"));
        assert!(context.contains("topic: cache key timeout"));
        assert!(context.contains("files: src/retrieval/memory_search/fts.rs fts.rs"));
        assert!(context.contains("symptom: requests timed out"));
        assert!(context.contains("root cause: cache key drift"));
        assert!(context.contains("fix: invalidating stale entries"));
        assert!(context.contains("verification: with `cargo test retrieval::memory_search`"));
        assert!(context.contains("commands: cargo test retrieval::memory_search"));
    }

    #[test]
    fn rebuild_all_regenerates_context_from_stored_metadata() -> anyhow::Result<()> {
        let conn = Connection::open_in_memory()?;
        crate::memory::types::tests_helper::setup_memory_schema(&conn);
        conn.execute(
            "INSERT INTO memories
             (id, session_id, project, topic_key, title, content, memory_type, files,
              created_at_epoch, updated_at_epoch, status, branch, scope)
             VALUES (1, NULL, 'proj', 'search-context-rebuild', 'Title',
                     'Issue: miss. Resolved by adding context. Verified with `cargo test`.',
                     'bugfix', '[\"src/memory/search_context.rs\"]',
                     100, 100, 'active', NULL, 'project')",
            [],
        )?;

        let changed = rebuild_all(&conn)?;
        assert_eq!(changed, 1);
        let context: String = conn.query_row(
            "SELECT search_context FROM memories WHERE id = 1",
            [],
            |row| row.get(0),
        )?;
        assert!(context.contains("search context rebuild"));
        assert!(context.contains("src/memory/search_context.rs"));
        assert!(context.contains("commands: cargo test"));
        Ok(())
    }

    #[test]
    fn rebuild_all_processes_multiple_batches() -> anyhow::Result<()> {
        let conn = Connection::open_in_memory()?;
        crate::memory::types::tests_helper::setup_memory_schema(&conn);

        let total = REBUILD_BATCH_SIZE + 3;
        for id in 1..=total {
            conn.execute(
                "INSERT INTO memories
                 (id, session_id, project, topic_key, title, content, memory_type, files,
                  created_at_epoch, updated_at_epoch, status, branch, scope)
                 VALUES (?1, NULL, 'proj', ?2, 'Title',
                         'Issue: miss. Resolved by adding context. Verified with `cargo test`.',
                         'bugfix', '[\"src/memory/search_context.rs\"]',
                         100, 100, 'active', NULL, 'project')",
                rusqlite::params![id, format!("search-context-rebuild-{id}")],
            )?;
        }

        let changed = rebuild_all(&conn)?;
        assert_eq!(changed, total as usize);
        let populated: i64 = conn.query_row(
            "SELECT COUNT(*) FROM memories WHERE search_context IS NOT NULL AND search_context != ''",
            [],
            |row| row.get(0),
        )?;
        assert_eq!(populated, total);
        Ok(())
    }
}