remem-ai 0.6.93

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::collections::HashSet;

use anyhow::Result;
use rusqlite::Connection;

use super::memory_selection::{
    context_cluster_suffix, normalize_cluster_text, reference_cluster_key,
};
use super::memory_traits::is_self_diagnostic_text;
use super::types::{ContextPreselectionDrop, ContextPreselectionItem, SessionSummaryBrief};

const SUMMARY_MAX_SCAN: usize = 200;
const STALE_DESIGN_SUMMARY_DAYS: i64 = 7;

pub(super) struct SummarySelection {
    pub selected: Vec<SessionSummaryBrief>,
    pub poisoning_drops: Vec<SessionSummaryBrief>,
    pub preselection_drops: Vec<ContextPreselectionDrop>,
}

#[cfg(test)]
pub(super) fn query_recent_summaries(
    conn: &Connection,
    project: &str,
    limit: usize,
) -> Result<Vec<SessionSummaryBrief>> {
    Ok(query_recent_summaries_with_drops(conn, project, limit)?.selected)
}

pub(super) fn query_recent_summaries_with_drops(
    conn: &Connection,
    project: &str,
    limit: usize,
) -> Result<SummarySelection> {
    query_recent_summaries_with_drops_matching(conn, project, limit, false)
}

pub(super) fn query_recent_unfinished_summaries_with_drops(
    conn: &Connection,
    project: &str,
    limit: usize,
) -> Result<SummarySelection> {
    query_recent_summaries_with_drops_matching(conn, project, limit, true)
}

fn query_recent_summaries_with_drops_matching(
    conn: &Connection,
    project: &str,
    limit: usize,
    require_next_steps: bool,
) -> Result<SummarySelection> {
    if limit == 0 {
        return Ok(SummarySelection {
            selected: Vec::new(),
            poisoning_drops: Vec::new(),
            preselection_drops: Vec::new(),
        });
    }

    let scan_limit = SUMMARY_MAX_SCAN.max(limit);
    let now_epoch = chrono::Utc::now().timestamp();
    let mut selected = Vec::new();
    let mut low_signal_fallback = Vec::new();
    let mut poisoning_drops = Vec::new();
    let mut preselection_drops = Vec::new();
    let mut seen_clusters = HashSet::new();
    let mut seen_session_keys = HashSet::new();
    let mut candidates = query_summary_batch(conn, project, scan_limit + 1, require_next_steps)?;
    let has_more = candidates.len() > scan_limit;
    candidates.truncate(scan_limit);
    for row in candidates {
        if selected.len() >= limit {
            break;
        }
        let injectable = if require_next_steps {
            crate::db::summary_poisoning::summary_injectable(
                conn,
                row.summary.id,
                &[
                    ("request", row.source_request.as_deref()),
                    ("completed", row.summary.completed.as_deref()),
                    ("decisions", row.decisions.as_deref()),
                    ("learned", row.learned.as_deref()),
                    ("next_steps", row.next_steps.as_deref()),
                    ("preferences", row.preferences.as_deref()),
                ],
                "prompt_submit_continuity",
            )
        } else {
            crate::db::summary_poisoning::summary_injectable(
                conn,
                row.summary.id,
                &[
                    ("request", Some(row.summary.request.as_str())),
                    ("completed", row.summary.completed.as_deref()),
                ],
                "context_recent_sessions",
            )
        };
        if !injectable {
            poisoning_drops.push(row.summary);
            continue;
        }
        if is_session_summary_self_diagnostic(&row) {
            preselection_drops.push(summary_drop(row.summary, "summary_self_diagnostic"));
            continue;
        }
        let summary = row.summary;

        let cluster_key = summary_cluster_key(&summary);
        if seen_clusters.contains(&cluster_key) {
            preselection_drops.push(summary_drop(summary, "summary_cluster_dedup"));
            continue;
        }
        if row
            .session_key
            .as_ref()
            .is_some_and(|session_key| seen_session_keys.contains(session_key))
        {
            preselection_drops.push(summary_drop(summary, "summary_session_dedup"));
            continue;
        }

        if is_stale_design_prototype_summary(&summary, now_epoch) {
            low_signal_fallback.push((cluster_key, row.session_key, summary));
            continue;
        }
        if selected.len() >= limit {
            preselection_drops.push(summary_drop(summary, "summary_item_limit"));
            continue;
        }

        seen_clusters.insert(cluster_key);
        if let Some(session_key) = row.session_key {
            seen_session_keys.insert(session_key);
        }
        selected.push(summary);
    }

    if selected.is_empty() {
        for (cluster_key, session_key, summary) in low_signal_fallback {
            if seen_clusters.contains(&cluster_key) {
                preselection_drops.push(summary_drop(summary, "summary_cluster_dedup"));
                continue;
            }
            if session_key
                .as_ref()
                .is_some_and(|key| seen_session_keys.contains(key))
            {
                preselection_drops.push(summary_drop(summary, "summary_session_dedup"));
                continue;
            }
            if selected.len() >= limit {
                preselection_drops.push(summary_drop(summary, "summary_item_limit"));
                continue;
            }
            seen_clusters.insert(cluster_key);
            if let Some(session_key) = session_key {
                seen_session_keys.insert(session_key);
            }
            selected.push(summary);
        }
    } else {
        preselection_drops.extend(
            low_signal_fallback
                .into_iter()
                .map(|(_, _, summary)| summary_drop(summary, "summary_stale_design_fallback")),
        );
    }

    if require_next_steps && selected.len() < limit && has_more {
        anyhow::bail!(
            "summary continuity scan budget exhausted after {scan_limit} rows before finding {limit} safe anchors"
        );
    }

    Ok(SummarySelection {
        selected,
        poisoning_drops,
        preselection_drops,
    })
}

fn summary_drop(summary: SessionSummaryBrief, reason: &'static str) -> ContextPreselectionDrop {
    ContextPreselectionDrop {
        item: ContextPreselectionItem::Summary(summary),
        reason,
    }
}

struct SessionSummaryQueryRow {
    summary: SessionSummaryBrief,
    session_key: Option<String>,
    source_request: Option<String>,
    decisions: Option<String>,
    learned: Option<String>,
    next_steps: Option<String>,
    preferences: Option<String>,
}

fn query_summary_batch(
    conn: &Connection,
    project: &str,
    limit: usize,
    require_next_steps: bool,
) -> Result<Vec<SessionSummaryQueryRow>> {
    let epoch_secs_only = crate::db::query::EPOCH_SECS_ONLY;
    let mut params_vec: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
    let (owner_clause, idx) = crate::project_alias::push_project_value_filter(
        conn,
        "ss.owner_key",
        project,
        1,
        &mut params_vec,
    )?;
    let (target_clause, idx) = crate::project_alias::push_project_value_filter(
        conn,
        "ss.target_project",
        project,
        idx,
        &mut params_vec,
    )?;
    let (legacy_clause, idx) = crate::project_alias::push_project_value_filter(
        conn,
        "ss.project",
        project,
        idx,
        &mut params_vec,
    )?;
    let limit_idx = idx;
    params_vec.push(Box::new(limit as i64));
    let next_steps_idx = limit_idx + 1;
    params_vec.push(Box::new(i64::from(require_next_steps)));
    let sql = format!(
        "SELECT ss.id, \
             CASE \
               WHEN ss.request LIKE 'Captured event range %..%' THEN \
                 COALESCE(NULLIF(TRIM(ss.decisions), ''), NULLIF(TRIM(ss.learned), ''), \
                          NULLIF(TRIM(ss.next_steps), ''), NULLIF(TRIM(ss.preferences), ''), \
                          NULLIF(TRIM(ss.completed), ''), ss.request) \
               ELSE COALESCE(NULLIF(TRIM(ss.request), ''), NULLIF(TRIM(ss.next_steps), ''), \
                             NULLIF(TRIM(ss.completed), ''), NULLIF(TRIM(ss.decisions), ''), \
                             NULLIF(TRIM(ss.learned), ''), NULLIF(TRIM(ss.preferences), ''), \
                             'Session summary #' || ss.id) \
             END AS display_request, \
             ss.completed, \
             ss.created_at_epoch, \
             CASE \
               WHEN ss.session_row_id IS NOT NULL AND s.session_id IS NOT NULL THEN \
                 'mem-' || substr(s.session_id, 1, 8) \
               ELSE ss.memory_session_id \
             END AS session_key, \
             ss.request, \
             ss.decisions, \
             ss.learned, \
             ss.next_steps, \
             ss.preferences \
         FROM session_summaries ss \
         LEFT JOIN sessions s ON s.id = ss.session_row_id \
         WHERE ss.{epoch_secs_only} \
           AND ((?{next_steps_idx} = 0 AND NULLIF(TRIM(ss.request), '') IS NOT NULL) \
                OR (?{next_steps_idx} = 1 AND NULLIF(TRIM(ss.next_steps), '') IS NOT NULL)) \
           AND COALESCE(ss.poisoning_status, 'legacy_unscanned') != 'quarantined' \
           AND (ss.session_row_id IS NULL \
                OR ss.request NOT LIKE 'Captured event range %..%' \
                OR COALESCE(ss.decisions, '') != '' \
                OR COALESCE(ss.learned, '') != '' \
                OR COALESCE(ss.next_steps, '') != '' \
                OR COALESCE(ss.preferences, '') != '') \
           AND ((ss.owner_scope = 'repo' AND {owner_clause}) \
                OR (ss.owner_scope = 'repo' AND {target_clause}) \
                OR (ss.owner_scope IS NULL AND {legacy_clause})) \
         ORDER BY ss.created_at_epoch DESC, display_request ASC, ss.completed ASC \
         LIMIT ?{limit_idx}",
    );
    let mut stmt = conn.prepare_cached(&sql)?;
    let refs = crate::db::to_sql_refs(&params_vec);
    let rows = stmt.query_map(refs.as_slice(), |row| {
        Ok(SessionSummaryQueryRow {
            summary: SessionSummaryBrief {
                id: row.get(0)?,
                request: row.get(1)?,
                completed: row.get(2)?,
                created_at_epoch: row.get(3)?,
            },
            session_key: row.get(4)?,
            source_request: row.get(5)?,
            decisions: row.get(6)?,
            learned: row.get(7)?,
            next_steps: row.get(8)?,
            preferences: row.get(9)?,
        })
    })?;
    crate::db::query::collect_rows(rows)
}

fn is_session_summary_self_diagnostic(row: &SessionSummaryQueryRow) -> bool {
    is_self_diagnostic_text(&session_summary_full_haystack(row))
}

fn is_stale_design_prototype_summary(summary: &SessionSummaryBrief, now_epoch: i64) -> bool {
    let age_days = (now_epoch - summary.created_at_epoch) / 86400;
    if age_days <= STALE_DESIGN_SUMMARY_DAYS {
        return false;
    }
    ["landing page", "wireframe", "starfield"]
        .iter()
        .any(|needle| session_summary_brief_haystack(summary).contains(needle))
}

fn summary_cluster_key(summary: &SessionSummaryBrief) -> String {
    let request = normalize_cluster_text(&summary.request);
    let tokens: Vec<&str> = request.split_whitespace().collect();
    if let Some(reference_key) = reference_cluster_key(&tokens) {
        return reference_key;
    }
    context_cluster_suffix(&request)
}

fn session_summary_full_haystack(row: &SessionSummaryQueryRow) -> String {
    [
        row.source_request.as_deref(),
        row.summary.completed.as_deref(),
        row.decisions.as_deref(),
        row.learned.as_deref(),
        row.next_steps.as_deref(),
        row.preferences.as_deref(),
    ]
    .into_iter()
    .flatten()
    .collect::<Vec<_>>()
    .join(" ")
    .to_ascii_lowercase()
}

fn session_summary_brief_haystack(summary: &SessionSummaryBrief) -> String {
    format!(
        "{} {}",
        summary.request,
        summary.completed.as_deref().unwrap_or_default()
    )
    .to_ascii_lowercase()
}