codex-recall 0.1.3

Local search and recall for Codex session JSONL archives
Documentation
use crate::commands::date::resolve_date_window;
use crate::commands::exclude::resolve_excluded_sessions;
use crate::commands::kind::{event_kinds, KindArg};
use crate::config::default_db_path;
use crate::output::shell_quote;
use crate::store::{RecentOptions, RecentSession, Store};
use anyhow::Result;
use clap::Args;
use serde_json::json;
use std::collections::BTreeMap;
use std::path::PathBuf;

#[derive(Debug, Clone, Args)]
pub struct RecentArgs {
    #[arg(long, help = "SQLite index path")]
    pub db: Option<PathBuf>,
    #[arg(long, default_value_t = 20, help = "Maximum sessions to print")]
    pub limit: usize,
    #[arg(long, help = "Restrict sessions to a repo name")]
    pub repo: Option<String>,
    #[arg(long, help = "Restrict sessions to a cwd substring")]
    pub cwd: Option<String>,
    #[arg(long, help = "Restrict by age, for example 7d, today, or 2026-04-01")]
    pub since: Option<String>,
    #[arg(long, help = "Restrict to sessions at or after this date/time")]
    pub from: Option<String>,
    #[arg(long, help = "Restrict to sessions before this date/time")]
    pub until: Option<String>,
    #[arg(long, help = "Restrict to one local calendar day, YYYY-MM-DD")]
    pub day: Option<String>,
    #[arg(
        long = "kind",
        value_enum,
        value_name = "KIND",
        help = "Restrict sessions by contained event kind; repeatable"
    )]
    pub kinds: Vec<KindArg>,
    #[arg(long, help = "Include duplicate active/archive copies")]
    pub include_duplicates: bool,
    #[arg(
        long = "exclude-session",
        help = "Exclude a session id or session key; repeatable"
    )]
    pub exclude_sessions: Vec<String>,
    #[arg(long, help = "Exclude the current Codex session from results")]
    pub exclude_current: bool,
    #[arg(long, help = "Emit machine-readable JSON")]
    pub json: bool,
}

#[derive(Debug, Clone, Args)]
pub struct DayArgs {
    #[arg(help = "Local calendar day as YYYY-MM-DD")]
    pub day: String,
    #[arg(long, help = "SQLite index path")]
    pub db: Option<PathBuf>,
    #[arg(long, default_value_t = 100, help = "Maximum sessions to include")]
    pub limit: usize,
    #[arg(long, help = "Restrict sessions to a repo name")]
    pub repo: Option<String>,
    #[arg(long, help = "Restrict sessions to a cwd substring")]
    pub cwd: Option<String>,
    #[arg(
        long = "kind",
        value_enum,
        value_name = "KIND",
        help = "Restrict sessions by contained event kind; repeatable"
    )]
    pub kinds: Vec<KindArg>,
    #[arg(long, help = "Include duplicate active/archive copies")]
    pub include_duplicates: bool,
    #[arg(
        long = "exclude-session",
        help = "Exclude a session id or session key; repeatable"
    )]
    pub exclude_sessions: Vec<String>,
    #[arg(long, help = "Exclude the current Codex session from results")]
    pub exclude_current: bool,
    #[arg(long, help = "Emit machine-readable JSON")]
    pub json: bool,
}

pub fn run_recent(args: RecentArgs) -> Result<()> {
    let db_path = args.db.unwrap_or(default_db_path()?);
    let store = Store::open_readonly(&db_path)?;
    let (since, from, until) = resolve_date_window(args.since, args.from, args.until, args.day)?;
    let exclude_sessions = resolve_excluded_sessions(args.exclude_sessions, args.exclude_current)?;
    let sessions = store.recent_sessions(RecentOptions {
        limit: args.limit,
        repo: args.repo,
        cwd: args.cwd,
        since,
        from,
        until,
        include_duplicates: args.include_duplicates,
        exclude_sessions,
        kinds: event_kinds(&args.kinds),
    })?;

    if args.json {
        print_recent_json(&sessions)?;
        return Ok(());
    }

    if sessions.is_empty() {
        println!("no recent sessions");
        return Ok(());
    }

    print_recent_sessions(&sessions);
    Ok(())
}

pub fn run_day(args: DayArgs) -> Result<()> {
    let db_path = args.db.unwrap_or(default_db_path()?);
    let store = Store::open_readonly(&db_path)?;
    let (_, from, until) = resolve_date_window(None, None, None, Some(args.day.clone()))?;
    let exclude_sessions = resolve_excluded_sessions(args.exclude_sessions, args.exclude_current)?;
    let sessions = store.recent_sessions(RecentOptions {
        limit: args.limit,
        repo: args.repo,
        cwd: args.cwd,
        since: None,
        from: from.clone(),
        until: until.clone(),
        include_duplicates: args.include_duplicates,
        exclude_sessions,
        kinds: event_kinds(&args.kinds),
    })?;

    if args.json {
        print_day_json(&args.day, from.as_deref(), until.as_deref(), &sessions)?;
        return Ok(());
    }

    if sessions.is_empty() {
        println!("no sessions for {}", args.day);
        return Ok(());
    }

    println!("{} sessions for {}", sessions.len(), args.day);
    print_recent_sessions(&sessions);
    Ok(())
}

pub(crate) fn print_recent_sessions(sessions: &[RecentSession]) {
    for (index, session) in sessions.iter().enumerate() {
        println!(
            "{}. {}  {}  {}",
            index + 1,
            session.session_key,
            session.session_id,
            session.repo
        );
        println!("   when: {}", session.session_timestamp);
        println!("   cwd: {}", session.cwd);
        println!("   source: {}", session.source_file_path.display());
        println!(
            "   show: codex-recall show {} --limit 120",
            shell_quote(&session.session_key)
        );
    }
}

fn print_recent_json(sessions: &[RecentSession]) -> Result<()> {
    let sessions_json = sessions_json(sessions);
    let value = json!({
        "count": sessions_json.len(),
        "sessions": sessions_json,
    });
    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}

fn print_day_json(
    day: &str,
    from: Option<&str>,
    until: Option<&str>,
    sessions: &[RecentSession],
) -> Result<()> {
    let repo_counts = count_by(sessions.iter().map(|session| session.repo.as_str()));
    let cwd_counts = count_by(sessions.iter().map(|session| session.cwd.as_str()));
    let sessions_json = sessions_json(sessions);
    let value = json!({
        "day": day,
        "from": from,
        "until": until,
        "count": sessions_json.len(),
        "repo_counts": repo_counts,
        "cwd_counts": cwd_counts,
        "sessions": sessions_json,
    });
    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}

fn sessions_json(sessions: &[RecentSession]) -> Vec<serde_json::Value> {
    sessions
        .iter()
        .map(|session| {
            json!({
                "session_key": session.session_key,
                "session_id": session.session_id,
                "repo": session.repo,
                "cwd": session.cwd,
                "session_timestamp": session.session_timestamp,
                "source_file_path": session.source_file_path,
                "show_command": format!("codex-recall show {} --limit 120", shell_quote(&session.session_key)),
            })
        })
        .collect()
}

fn count_by<'a>(values: impl Iterator<Item = &'a str>) -> BTreeMap<String, usize> {
    let mut counts = BTreeMap::new();
    for value in values {
        *counts.entry(value.to_owned()).or_insert(0) += 1;
    }
    counts
}