use crate::model::Message;
use serde::{Deserialize, Serialize};
use std::{
fs::{self, OpenOptions},
io::{BufRead, BufReader, Write},
path::{Path, PathBuf},
time::Instant,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub agent: String,
pub created_by: String,
pub created_at: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub uptime_secs: u64,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum SessionLine {
Compact { compact: String },
Message(Message),
}
#[derive(Debug, Clone)]
pub struct Session {
pub id: u64,
pub agent: String,
pub history: Vec<Message>,
pub created_by: String,
pub title: String,
pub uptime_secs: u64,
pub created_at: Instant,
pub file_path: Option<PathBuf>,
}
impl Session {
pub fn new(id: u64, agent: impl Into<String>, created_by: impl Into<String>) -> Self {
Self {
id,
agent: agent.into(),
history: Vec::new(),
created_by: created_by.into(),
title: String::new(),
uptime_secs: 0,
created_at: Instant::now(),
file_path: None,
}
}
pub fn init_file(&mut self, sessions_dir: &Path) {
let _ = fs::create_dir_all(sessions_dir);
let slug = sender_slug(&self.created_by);
let prefix = format!("{}_{slug}_", self.agent);
let seq = next_seq(sessions_dir, &prefix);
let filename = format!("{prefix}{seq}.jsonl");
let path = sessions_dir.join(filename);
let meta = SessionMeta {
agent: self.agent.clone(),
created_by: self.created_by.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
title: String::new(),
uptime_secs: self.uptime_secs,
};
match OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&path)
{
Ok(mut f) => {
if let Ok(json) = serde_json::to_string(&meta) {
let _ = writeln!(f, "{json}");
}
self.file_path = Some(path);
}
Err(e) => tracing::warn!("failed to create session file: {e}"),
}
}
pub fn set_title(&mut self, title: &str) {
self.title = title.to_string();
let Some(ref old_path) = self.file_path else {
return;
};
self.rewrite_meta();
let title_slug = sender_slug(title);
if title_slug.is_empty() {
return;
}
let old_name = old_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let new_name = format!("{old_name}_{title_slug}.jsonl");
let new_path = old_path.with_file_name(new_name);
if fs::rename(old_path, &new_path).is_ok() {
self.file_path = Some(new_path);
}
}
pub fn rewrite_meta(&self) {
let Some(ref path) = self.file_path else {
return;
};
let Ok(content) = fs::read_to_string(path) else {
return;
};
let meta = SessionMeta {
agent: self.agent.clone(),
created_by: self.created_by.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
title: self.title.clone(),
uptime_secs: self.uptime_secs,
};
let Ok(meta_json) = serde_json::to_string(&meta) else {
return;
};
let rest = content.find('\n').map(|i| &content[i..]).unwrap_or("");
let new_content = format!("{meta_json}{rest}");
let _ = fs::write(path, new_content);
}
pub fn append_messages(&self, messages: &[Message]) {
let Some(ref path) = self.file_path else {
return;
};
let mut file = match OpenOptions::new().append(true).open(path) {
Ok(f) => f,
Err(e) => {
tracing::warn!("failed to open session file for append: {e}");
return;
}
};
for msg in messages {
if msg.auto_injected {
continue;
}
if let Ok(json) = serde_json::to_string(msg) {
let _ = writeln!(file, "{json}");
}
}
}
pub fn append_compact(&self, summary: &str) {
let Some(ref path) = self.file_path else {
return;
};
let mut file = match OpenOptions::new().append(true).open(path) {
Ok(f) => f,
Err(e) => {
tracing::warn!("failed to open session file for compact: {e}");
return;
}
};
let line = SessionLine::Compact {
compact: summary.to_string(),
};
if let Ok(json) = serde_json::to_string(&line) {
let _ = writeln!(file, "{json}");
}
}
pub fn load_context(path: &Path) -> anyhow::Result<(SessionMeta, Vec<Message>)> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut lines = reader.lines();
let meta_line = lines
.next()
.ok_or_else(|| anyhow::anyhow!("empty session file"))??;
let meta: SessionMeta = serde_json::from_str(&meta_line)?;
let mut all_lines: Vec<String> = Vec::new();
let mut last_compact_idx: Option<usize> = None;
for line in lines {
let line = line?;
if line.trim().is_empty() {
continue;
}
if line.contains("\"compact\"")
&& let Ok(SessionLine::Compact { .. }) = serde_json::from_str(&line)
{
last_compact_idx = Some(all_lines.len());
}
all_lines.push(line);
}
let context_start = last_compact_idx.unwrap_or_default();
let mut messages = Vec::new();
for (i, line) in all_lines[context_start..].iter().enumerate() {
if i == 0
&& last_compact_idx.is_some()
&& let Ok(SessionLine::Compact { compact }) = serde_json::from_str(line)
{
messages.push(Message::user(&compact));
continue;
}
if let Ok(msg) = serde_json::from_str::<Message>(line) {
messages.push(msg);
}
}
Ok((meta, messages))
}
}
pub fn find_latest_session(sessions_dir: &Path, agent: &str, created_by: &str) -> Option<PathBuf> {
let slug = sender_slug(created_by);
let prefix = format!("{agent}_{slug}_");
let mut best: Option<(u32, PathBuf)> = None;
for entry in fs::read_dir(sessions_dir).ok()?.flatten() {
let path = entry.path();
if path.is_dir() {
continue;
}
let name = path.file_name()?.to_str()?;
if !name.starts_with(&prefix) || !name.ends_with(".jsonl") {
continue;
}
let after_prefix = &name[prefix.len()..];
let seq_str = after_prefix.split(|c: char| !c.is_ascii_digit()).next()?;
let seq: u32 = seq_str.parse().ok()?;
if best.as_ref().is_none_or(|(best_seq, _)| seq > *best_seq) {
best = Some((seq, path));
}
}
best.map(|(_, path)| path)
}
fn next_seq(dir: &Path, prefix: &str) -> u32 {
let max = fs::read_dir(dir)
.ok()
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let name = e.file_name();
let name = name.to_str()?;
if !name.starts_with(prefix) || !name.ends_with(".jsonl") {
return None;
}
let after_prefix = &name[prefix.len()..];
let seq_str = after_prefix.split(|c: char| !c.is_ascii_digit()).next()?;
seq_str.parse::<u32>().ok()
})
.max()
.unwrap_or(0);
max + 1
}
pub fn sender_slug(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}