use crate::provider::{ChatMessage, ContentPart, Provider, Role};
use crate::session::{SessionMeta, SessionStore, StoredMessage};
use chrono::{Local, NaiveDate};
use sapphire_workspace::WorkspaceState;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tracing::{info, warn};
pub fn pending_log_dates(
session_store: &SessionStore,
workspace_dir: &Path,
boundary_hour: u8,
) -> Vec<NaiveDate> {
let today = crate::session::local_date_for_timestamp(Local::now(), boundary_hour);
let mut dates = session_store.all_session_dates(boundary_hour);
dates.retain(|&date| date < today && !daily_log_path(workspace_dir, date).exists());
dates
}
pub async fn generate_daily_log(
session_store: &SessionStore,
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
date: NaiveDate,
boundary_hour: u8,
) -> anyhow::Result<()> {
let sessions = session_store.sessions_for_day(date, boundary_hour);
if sessions.is_empty() {
info!("No sessions found for {date}, skipping daily log");
return Ok(());
}
let transcript = format_sessions(&sessions, date);
let system = "You are generating a concise daily log entry from conversation transcripts. \
Summarize: key topics discussed, decisions made, tasks completed, and unresolved items. \
Write in the same language as the conversations (Japanese if conversations are in Japanese). \
Output plain Markdown suitable for a daily log file. \
Do not include a top-level heading — that will be added automatically.";
let user_msg = ChatMessage::user(&transcript);
let response = provider.chat(Some(system), &[user_msg], None).await?;
let summary = response
.text
.unwrap_or_else(|| "(no summary generated)".to_string());
let rel = daily_log_rel_path(date);
let content = format!("# Daily Log: {date}\n\n{summary}\n");
ws_state
.lock()
.expect("WorkspaceState mutex poisoned")
.write_file(&rel, &content)?;
info!("Daily log written: {}", rel.display());
Ok(())
}
pub fn daily_log_path(workspace_dir: &Path, date: NaiveDate) -> PathBuf {
workspace_dir.join(daily_log_rel_path(date))
}
fn daily_log_rel_path(date: NaiveDate) -> PathBuf {
Path::new("memory")
.join("daily")
.join(format!("{date}.md"))
}
fn format_sessions(sessions: &[(SessionMeta, Vec<StoredMessage>)], date: NaiveDate) -> String {
let mut parts = vec![format!("Conversations for {date}:\n")];
for (meta, messages) in sessions {
let thread = meta.thread_id.as_deref().unwrap_or("main");
parts.push(format!(
"## Session {} (thread: {})\n",
meta.session_id, thread
));
for msg in messages {
let text: Vec<&str> = msg
.parts
.iter()
.filter_map(|p| {
if let ContentPart::Text(t) = p {
Some(t.as_str())
} else {
None
}
})
.collect();
if text.is_empty() {
continue;
}
let role_label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
};
let local_ts = msg.timestamp.with_timezone(&Local);
parts.push(format!(
"[{}] {}: {}",
local_ts.format("%H:%M"),
role_label,
text.join(" ")
));
}
parts.push(String::new());
}
parts.join("\n")
}
pub async fn catchup_pending_logs(
session_store: &SessionStore,
provider: &dyn Provider,
ws_state: &Arc<Mutex<WorkspaceState>>,
workspace_dir: &Path,
boundary_hour: u8,
) {
let pending = pending_log_dates(session_store, workspace_dir, boundary_hour);
if pending.is_empty() {
return;
}
info!("Generating {} pending daily log(s)…", pending.len());
for date in pending {
if let Err(e) =
generate_daily_log(session_store, provider, ws_state, date, boundary_hour).await
{
warn!("Failed to generate daily log for {date}: {e:#}");
}
}
}