remem-ai 0.6.39

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use anyhow::Result;
use serde::Serialize;

use crate::{
    db,
    user_context::summary::{self, SummaryEditRequest, SummaryRequest, UserContextSummary},
};

use super::shared::resolve_cwd_project;
use crate::cli::query_types::UserSummaryAction;

pub(in crate::cli) fn run_user_summary(action: UserSummaryAction) -> Result<()> {
    let conn = db::open_db()?;
    match action {
        UserSummaryAction::Show {
            project,
            scope,
            owner_key,
            json,
        } => {
            let project = resolve_project(project);
            let req = request(scope.db_value(), owner_key.as_deref(), &project);
            let summary = summary::load_active_summary(&conn, &req)?;
            if json {
                print_json(&SummaryShowOutput {
                    found: summary.is_some(),
                    summary,
                })?;
            } else if let Some(summary) = summary {
                print_summary(&summary);
            } else {
                println!("No user-context summary found.");
            }
        }
        UserSummaryAction::Refresh {
            project,
            scope,
            owner_key,
            json,
        } => {
            let project = resolve_project(project);
            let req = request(scope.db_value(), owner_key.as_deref(), &project);
            let summary = summary::refresh_summary(&conn, &req)?;
            print_status("refreshed", summary, json)?;
        }
        UserSummaryAction::Edit {
            project,
            scope,
            owner_key,
            text,
            json,
        } => {
            let project = resolve_project(project);
            let summary = summary::edit_summary(
                &conn,
                &SummaryEditRequest {
                    owner_scope: Some(scope.db_value()),
                    owner_key: owner_key.as_deref(),
                    project: &project,
                    text: &text,
                },
            )?;
            print_status("edited", summary, json)?;
        }
        UserSummaryAction::Sources {
            project,
            scope,
            owner_key,
            include_excluded,
            json,
        } => {
            let project = resolve_project(project);
            let req = request(scope.db_value(), owner_key.as_deref(), &project);
            let sources = summary::load_summary_sources(&conn, &req, include_excluded)?;
            if json {
                print_json(&sources)?;
            } else {
                print_sources(&sources);
            }
        }
    }
    Ok(())
}

fn resolve_project(project: Option<String>) -> String {
    project.unwrap_or_else(|| resolve_cwd_project().1)
}

fn request<'a>(
    owner_scope: &'a str,
    owner_key: Option<&'a str>,
    project: &'a str,
) -> SummaryRequest<'a> {
    SummaryRequest {
        owner_scope: Some(owner_scope),
        owner_key,
        project,
    }
}

fn print_summary(summary: &UserContextSummary) {
    println!(
        "User-context summary {} (version {}, {}, {}:{})",
        summary.id, summary.version, summary.status, summary.owner_scope, summary.owner_key
    );
    println!(
        "Sources: {} claim(s), {} memory/memories, {} activity ref(s)",
        summary.source_claim_ids.len(),
        summary.source_memory_ids.len(),
        summary.source_activity_refs.len()
    );
    println!();
    println!("{}", summary.summary_text);
}

fn print_sources(sources: &summary::SummarySources) {
    match &sources.summary {
        Some(summary) => println!("Summary: {} (version {})", summary.id, summary.version),
        None => println!("Summary: none"),
    }
    println!("Claims:");
    for claim in &sources.included_claims {
        println!("  {} [{}] {}", claim.id, claim.claim_type, claim.claim_text);
    }
    println!("Memories:");
    for memory in &sources.included_memories {
        println!("  {} [{}] {}", memory.id, memory.memory_type, memory.title);
    }
    println!("Activity:");
    for activity in &sources.included_activity_refs {
        println!("  {}:{} {}", activity.kind, activity.id, activity.label);
    }
    if !sources.dropped_claims.is_empty() {
        println!("Excluded claims:");
        for dropped in &sources.dropped_claims {
            println!("  {}:{} {}", dropped.kind, dropped.id, dropped.reason);
        }
    }
}

fn print_status(status: &'static str, summary: UserContextSummary, json: bool) -> Result<()> {
    if json {
        print_json(&SummaryStatusOutput { status, summary })?;
    } else {
        println!(
            "User-context summary {} {} (version {}).",
            summary.id, status, summary.version
        );
    }
    Ok(())
}

fn print_json<T: Serialize>(value: &T) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

#[derive(Serialize)]
struct SummaryShowOutput {
    found: bool,
    summary: Option<UserContextSummary>,
}

#[derive(Serialize)]
struct SummaryStatusOutput {
    status: &'static str,
    summary: UserContextSummary,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{
        cli::query_types::{UserClaimScopeArg, UserSummaryAction},
        user_context::claims::{
            create_manual_claim, ManualClaimRequest, UserContextClaimType, UserContextSensitivity,
        },
    };

    #[test]
    fn user_summary_actions_refresh_show_sources_and_edit() -> Result<()> {
        let _dir = crate::db::test_support::ScopedTestDataDir::new("user-summary-cli-actions");
        let conn = db::open_db()?;
        create_manual_claim(
            &conn,
            &ManualClaimRequest {
                text: "Prefer summary sources",
                owner_scope: None,
                owner_key: None,
                claim_type: UserContextClaimType::Preference,
                claim_key: Some("pref:summary"),
                confidence: 1.0,
                sensitivity: UserContextSensitivity::Normal,
                valid_from_epoch: None,
                valid_to_epoch: None,
            },
        )?;
        drop(conn);

        run_user_summary(UserSummaryAction::Refresh {
            project: Some("/repo".to_string()),
            scope: UserClaimScopeArg::User,
            owner_key: None,
            json: true,
        })?;
        run_user_summary(UserSummaryAction::Show {
            project: Some("/repo".to_string()),
            scope: UserClaimScopeArg::User,
            owner_key: None,
            json: true,
        })?;
        run_user_summary(UserSummaryAction::Sources {
            project: Some("/repo".to_string()),
            scope: UserClaimScopeArg::User,
            owner_key: None,
            include_excluded: true,
            json: true,
        })?;
        run_user_summary(UserSummaryAction::Edit {
            project: Some("/repo".to_string()),
            scope: UserClaimScopeArg::User,
            owner_key: None,
            text: "Edited summary".to_string(),
            json: true,
        })?;

        let conn = db::open_db()?;
        let active = summary::load_active_summary(
            &conn,
            &SummaryRequest {
                owner_scope: None,
                owner_key: None,
                project: "/repo",
            },
        )?
        .ok_or_else(|| anyhow::anyhow!("summary missing"))?;
        assert_eq!(active.version, 2);
        assert_eq!(active.summary_text, "Edited summary");
        Ok(())
    }

    #[test]
    fn user_summary_json_outputs_match_subcommand_shapes() -> Result<()> {
        let summary = UserContextSummary {
            id: 1,
            user_key: "user:default".to_string(),
            owner_scope: "user".to_string(),
            owner_key: "user:default".to_string(),
            scope: "project".to_string(),
            scope_key: Some("/repo".to_string()),
            summary_text: "Profile summary".to_string(),
            source_claim_ids: vec![1],
            source_memory_ids: vec![2],
            source_activity_refs: Vec::new(),
            status: "active".to_string(),
            model: Some("deterministic-profile-v1".to_string()),
            version: 1,
            created_at_epoch: 10,
            updated_at_epoch: 10,
        };
        let show = serde_json::to_value(&SummaryShowOutput {
            found: true,
            summary: Some(summary.clone()),
        })?;
        assert!(show.get("found").is_some());
        assert!(show.get("summary").is_some());
        assert!(show.get("status").is_none());

        let status = serde_json::to_value(&SummaryStatusOutput {
            status: "refreshed",
            summary,
        })?;
        assert!(status.get("status").is_some());
        assert!(status.get("summary").is_some());
        Ok(())
    }
}