use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use crate::cli::OutputFormat;
use crate::storage::{Annotation, Database, MessageContent, Summary, Tag};
const MESSAGE_PREVIEW_LENGTH: usize = 200;
const LAST_MESSAGES_COUNT: usize = 3;
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore context Show last 5 sessions for current repo\n \
lore context --repo /path Show sessions for specific path\n \
lore context --last Show detailed summary of most recent session\n \
lore context --json Output as JSON")]
pub struct Args {
#[arg(short, long, value_name = "PATH")]
pub repo: Option<String>,
#[arg(long)]
pub last: bool,
#[arg(short, long, value_enum, default_value = "text")]
pub format: OutputFormat,
}
#[derive(Serialize)]
struct SessionSummary {
id: String,
id_short: String,
tool: String,
started_at: String,
message_count: i32,
linked_commits: Vec<String>,
working_directory: String,
git_branch: Option<String>,
}
#[derive(Serialize)]
struct DetailedSessionOutput {
id: String,
id_short: String,
tool: String,
started_at: String,
message_count: i32,
linked_commits: Vec<String>,
working_directory: String,
git_branch: Option<String>,
summary: Option<String>,
annotations: Vec<AnnotationInfo>,
tags: Vec<String>,
recent_messages: Vec<MessagePreview>,
}
#[derive(Serialize)]
struct AnnotationInfo {
content: String,
created_at: String,
}
#[derive(Serialize)]
struct MessagePreview {
role: String,
content: String,
timestamp: String,
}
#[derive(Serialize)]
struct ContextOutput {
working_directory: String,
sessions: Vec<SessionSummary>,
}
pub fn run(args: Args) -> Result<()> {
let db = Database::open_default()?;
let working_dir = match args.repo {
Some(ref r) if r == "." => std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| r.clone()),
Some(r) => r,
None => std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
};
let limit = if args.last { 1 } else { 5 };
let sessions = db.list_sessions(limit, Some(&working_dir))?;
if sessions.is_empty() {
match args.format {
OutputFormat::Json => {
let output = ContextOutput {
working_directory: working_dir,
sessions: vec![],
};
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
}
OutputFormat::Text | OutputFormat::Markdown => {
println!("{}", "No sessions found for this directory.".dimmed());
println!();
println!("Run 'lore import' to import sessions from AI coding tools.");
}
}
return Ok(());
}
let mut summaries = Vec::new();
for session in &sessions {
let links = db.get_links_by_session(&session.id)?;
let linked_commits: Vec<String> = links
.iter()
.filter_map(|l| l.commit_sha.as_ref())
.map(|sha| sha[..8.min(sha.len())].to_string())
.collect();
summaries.push(SessionSummary {
id: session.id.to_string(),
id_short: session.id.to_string()[..8].to_string(),
tool: session.tool.clone(),
started_at: session.started_at.to_rfc3339(),
message_count: session.message_count,
linked_commits,
working_directory: session.working_directory.clone(),
git_branch: session.git_branch.clone(),
});
}
match args.format {
OutputFormat::Json => {
if args.last {
let session = &sessions[0];
let summary_struct = &summaries[0];
output_detailed_json(&db, session, summary_struct)?;
} else {
let output = ContextOutput {
working_directory: working_dir,
sessions: summaries,
};
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
}
}
OutputFormat::Text | OutputFormat::Markdown => {
if args.last {
let summary = &summaries[0];
let session = &sessions[0];
println!("{} {}", "Session".bold(), summary.id_short.cyan());
println!();
println!(" {} {}", "Tool:".dimmed(), session.tool);
println!(
" {} {}",
"Started:".dimmed(),
session.started_at.format("%Y-%m-%d %H:%M:%S")
);
println!(" {} {}", "Messages:".dimmed(), session.message_count);
println!(" {} {}", "Directory:".dimmed(), session.working_directory);
if let Some(ref branch) = session.git_branch {
println!(" {} {}", "Branch:".dimmed(), branch);
}
if !summary.linked_commits.is_empty() {
println!();
println!("{}", "Linked commits:".bold());
for sha in &summary.linked_commits {
println!(" {} {}", "commit".dimmed(), sha.yellow());
}
}
if let Some(session_summary) = db.get_summary(&session.id)? {
println!();
println!("{}", "Summary:".bold());
println!(" {}", session_summary.content);
}
let tags = db.get_tags(&session.id)?;
if !tags.is_empty() {
println!();
println!("{}", "Tags:".bold());
print_tags(&tags);
}
let annotations = db.get_annotations(&session.id)?;
if !annotations.is_empty() {
println!();
println!("{}", "Recent annotations:".bold());
print_annotations(&annotations);
}
let messages = db.get_messages(&session.id)?;
if !messages.is_empty() {
println!();
println!("{}", "Recent messages:".bold());
let start = messages.len().saturating_sub(LAST_MESSAGES_COUNT);
for msg in &messages[start..] {
let role = match msg.role {
crate::storage::MessageRole::User => "Human".green(),
crate::storage::MessageRole::Assistant => "Assistant".blue(),
crate::storage::MessageRole::System => "System".yellow(),
};
let content = truncate_content(&msg.content, MESSAGE_PREVIEW_LENGTH);
println!(" [{}] {}", role, content.dimmed());
}
}
println!();
println!(
"{}",
format!(
"Use 'lore show {}' for full conversation history.",
&summary.id_short
)
.dimmed()
);
} else {
println!(
"{}",
format!(
"{:<8} {:<12} {:>8} {:>8} {}",
"ID", "TOOL", "MESSAGES", "COMMITS", "STARTED"
)
.bold()
);
for summary in &summaries {
let commits_display = if summary.linked_commits.is_empty() {
"-".to_string()
} else {
summary.linked_commits.len().to_string()
};
let started_display = chrono::DateTime::parse_from_rfc3339(&summary.started_at)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|_| summary.started_at.clone());
println!(
"{:<8} {:<12} {:>8} {:>8} {}",
summary.id_short.cyan(),
summary.tool,
summary.message_count,
commits_display,
started_display.dimmed()
);
}
}
}
}
Ok(())
}
fn output_detailed_json(
db: &Database,
session: &crate::storage::Session,
summary_struct: &SessionSummary,
) -> Result<()> {
let session_summary: Option<Summary> = db.get_summary(&session.id)?;
let annotations = db.get_annotations(&session.id)?;
let tags = db.get_tags(&session.id)?;
let messages = db.get_messages(&session.id)?;
let start = messages.len().saturating_sub(LAST_MESSAGES_COUNT);
let recent_messages: Vec<MessagePreview> = messages[start..]
.iter()
.map(|msg| MessagePreview {
role: msg.role.to_string(),
content: truncate_content(&msg.content, MESSAGE_PREVIEW_LENGTH),
timestamp: msg.timestamp.to_rfc3339(),
})
.collect();
let output = DetailedSessionOutput {
id: summary_struct.id.clone(),
id_short: summary_struct.id_short.clone(),
tool: summary_struct.tool.clone(),
started_at: summary_struct.started_at.clone(),
message_count: summary_struct.message_count,
linked_commits: summary_struct.linked_commits.clone(),
working_directory: summary_struct.working_directory.clone(),
git_branch: summary_struct.git_branch.clone(),
summary: session_summary.map(|s| s.content),
annotations: annotations
.into_iter()
.map(|a| AnnotationInfo {
content: a.content,
created_at: a.created_at.to_rfc3339(),
})
.collect(),
tags: tags.into_iter().map(|t| t.label).collect(),
recent_messages,
};
let json = serde_json::to_string_pretty(&output)?;
println!("{json}");
Ok(())
}
fn print_tags(tags: &[Tag]) {
let labels: Vec<String> = tags.iter().map(|t| t.label.yellow().to_string()).collect();
println!(" {}", labels.join(", "));
}
fn print_annotations(annotations: &[Annotation]) {
let start = annotations.len().saturating_sub(3);
for ann in &annotations[start..] {
let time = ann.created_at.format("%Y-%m-%d %H:%M").to_string();
println!(" [{}] {}", time.dimmed(), ann.content);
}
}
fn truncate_content(content: &MessageContent, max_len: usize) -> String {
let text = content.text();
let text = text.replace('\n', " ");
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
if text.len() <= max_len {
text
} else {
format!("{}...", &text[..max_len.saturating_sub(3)])
}
}