remem-ai 0.6.11

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

use crate::cli::types::TimelineAction;
use crate::{db, retrieval::search::search_observations};

use super::show::format_memory_timestamp;

pub(in crate::cli) fn run_timeline(action: TimelineAction) -> Result<()> {
    match action {
        TimelineAction::Around {
            anchor,
            query,
            project,
            depth_before,
            depth_after,
            json,
        } => run_timeline_around(
            anchor,
            query.as_deref(),
            project.as_deref(),
            depth_before,
            depth_after,
            json,
        ),
        TimelineAction::Report {
            project,
            full,
            json,
        } => run_timeline_report(&project, full, json),
    }
}

fn run_timeline_around(
    anchor: Option<i64>,
    query: Option<&str>,
    project: Option<&str>,
    depth_before: i64,
    depth_after: i64,
    json: bool,
) -> Result<()> {
    let conn = db::open_db()?;
    let anchor_id = resolve_anchor(&conn, anchor, query, project)?;
    let before = depth_before.max(0);
    let after = depth_after.max(0);
    let results = db::get_timeline_around(&conn, anchor_id, before, after, project)?;
    if json {
        let output = TimelineAroundJson {
            anchor_id,
            query: query.map(str::to_string),
            project: project.map(str::to_string),
            depth_before: before,
            depth_after: after,
            count: results.len(),
            results,
        };
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }
    print!("{}", render_timeline_around(anchor_id, &results));
    Ok(())
}

fn resolve_anchor(
    conn: &rusqlite::Connection,
    anchor: Option<i64>,
    query: Option<&str>,
    project: Option<&str>,
) -> Result<i64> {
    if let Some(anchor) = anchor {
        let found = db::get_observations_by_ids(conn, &[anchor], project)?;
        if found.is_empty() {
            bail!("No observation found for anchor id {anchor}");
        }
        return Ok(anchor);
    }
    let Some(query) = query else {
        bail!("timeline around requires --anchor or --query");
    };
    let results = search_observations(conn, Some(query), project, None, 1, 0, true)?;
    let Some(result) = results.first() else {
        bail!("No results for query '{query}'");
    };
    Ok(result.id)
}

fn run_timeline_report(project: &str, full: bool, json: bool) -> Result<()> {
    let conn = db::open_db()?;
    if json {
        let output = TimelineReportJson {
            project: project.to_string(),
            full,
            report: crate::timeline::generate_timeline_report_data(&conn, project, full)?,
        };
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }
    let report = crate::timeline::generate_timeline_report(&conn, project, full)?;
    print!("{report}");
    Ok(())
}

fn render_timeline_around(anchor_id: i64, results: &[db::Observation]) -> String {
    let mut output = format!("Timeline around observation #{anchor_id}:\n\n");
    for observation in results {
        let marker = if observation.id == anchor_id {
            "*"
        } else {
            " "
        };
        let title = observation.title.as_deref().unwrap_or("(untitled)");
        output.push_str(&format!(
            "{} #{} [{}] {} {}\n",
            marker,
            observation.id,
            observation.r#type,
            format_memory_timestamp(observation.created_at_epoch),
            title
        ));
    }
    output
}

#[derive(Debug, Serialize)]
struct TimelineAroundJson {
    anchor_id: i64,
    query: Option<String>,
    project: Option<String>,
    depth_before: i64,
    depth_after: i64,
    count: usize,
    results: Vec<db::Observation>,
}

#[derive(Debug, Serialize)]
struct TimelineReportJson {
    project: String,
    full: bool,
    report: crate::timeline::TimelineReportData,
}

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

    #[test]
    fn query_anchor_resolves_observation_id_not_memory_id() -> Result<()> {
        let conn = Connection::open_in_memory()?;
        crate::migrate::run_migrations(&conn)?;

        let memory_id = crate::memory::insert_memory(
            &conn,
            Some("m1"),
            "/repo",
            None,
            "Release manifest memory",
            "release manifest from memory",
            "decision",
            None,
        )?;
        let unrelated_observation_id = crate::db::insert_observation(
            &conn,
            "s0",
            "/repo",
            "decision",
            Some("Unrelated observation"),
            None,
            Some("unrelated operational note"),
            None,
            None,
            None,
            None,
            None,
            0,
        )?;
        let matching_observation_id = crate::db::insert_observation(
            &conn,
            "s1",
            "/repo",
            "decision",
            Some("Release manifest observation"),
            None,
            Some("release manifest observation anchor"),
            None,
            None,
            None,
            None,
            None,
            0,
        )?;

        assert_eq!(memory_id, unrelated_observation_id);
        assert_ne!(memory_id, matching_observation_id);

        let resolved = resolve_anchor(&conn, None, Some("release manifest"), Some("/repo"))?;
        assert_eq!(resolved, matching_observation_id);
        Ok(())
    }
}