use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
use crate::compress::CompressionHistoryEntry;
use crate::providers::Message;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMetadata {
pub id: String,
pub name: Option<String>,
pub project_path: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub message_count: usize,
pub last_input_tokens: u64,
pub total_output_tokens: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compression_history: Vec<CompressionHistoryEntry>,
}
impl SessionMetadata {
pub fn new(project_path: Option<&Path>) -> Self {
let now = Utc::now();
Self {
id: Uuid::new_v4().to_string(),
name: None, project_path: project_path.map(|p| p.to_string_lossy().to_string()),
created_at: now,
updated_at: now,
message_count: 0,
last_input_tokens: 0,
total_output_tokens: 0,
compression_history: Vec::new(),
}
}
fn generate_time_name(time: DateTime<Utc>) -> String {
let local: chrono::DateTime<chrono::Local> = time.with_timezone(&chrono::Local);
local.format("%Y-%m-%d %H:%M").to_string()
}
pub fn add_compression_entry(&mut self, entry: CompressionHistoryEntry) {
self.compression_history.push(entry);
if self.compression_history.len() > 10 {
self.compression_history.remove(0);
}
}
pub fn total_tokens_saved(&self) -> u32 {
self.compression_history
.iter()
.map(|e| e.tokens_saved)
.sum()
}
pub fn compression_count(&self) -> usize {
self.compression_history.len()
}
pub fn display_name(&self) -> String {
if let Some(ref name) = self.name {
name.clone()
} else {
Self::generate_time_name(self.created_at)
}
}
pub fn short_id(&self) -> String {
self.id[..8].to_string()
}
pub fn format_line(&self, is_current: bool) -> String {
let marker = if is_current { "*" } else { " " };
let name = self.display_name();
let msgs = self.message_count;
let project = self
.project_path
.as_ref()
.map(|p| {
PathBuf::from(p)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| p.clone())
})
.unwrap_or_else(|| "-".to_string());
let compression_info = if self.compression_count() > 0 {
format!(" 💾 {} comps", self.compression_count())
} else {
"".to_string()
};
format!(
"{} {} {} msgs {}{}",
marker, name, msgs, project, compression_info
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SessionIndex {
pub sessions: Vec<SessionMetadata>,
pub last_session_id: Option<String>,
}
impl SessionIndex {
pub fn find(&self, query: &str) -> Option<&SessionMetadata> {
if let Some(s) = self.sessions.iter().find(|s| s.id == query) {
return Some(s);
}
if let Some(s) = self
.sessions
.iter()
.find(|s| s.name.as_deref() == Some(query))
{
return Some(s);
}
if let Some(s) = self.sessions.iter().find(|s| s.id.starts_with(query)) {
return Some(s);
}
None
}
pub fn last_session(&self) -> Option<&SessionMetadata> {
self.last_session_id
.as_ref()
.and_then(|id| self.sessions.iter().find(|s| s.id == *id))
}
pub fn upsert(&mut self, meta: SessionMetadata) {
self.sessions.retain(|s| s.id != meta.id);
self.sessions.push(meta.clone());
self.last_session_id = Some(meta.id);
self.sessions
.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
}
pub fn remove(&mut self, id: &str) -> Option<SessionMetadata> {
let removed = self.sessions.iter().position(|s| s.id == id);
if let Some(idx) = removed {
let meta = self.sessions.remove(idx);
if self.last_session_id.as_deref() == Some(id) {
self.last_session_id = self.sessions.first().map(|s| s.id.clone());
}
Some(meta)
} else {
None
}
}
pub fn rename(&mut self, id: &str, new_name: &str) -> Result<()> {
let session = self.sessions.iter_mut().find(|s| s.id == id);
if let Some(s) = session {
s.name = Some(new_name.to_string());
s.updated_at = Utc::now();
Ok(())
} else {
anyhow::bail!("session {} not found", id)
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub metadata: SessionMetadata,
pub messages: Vec<Message>,
}
impl Session {
pub fn new(project_path: Option<&Path>) -> Self {
Self {
metadata: SessionMetadata::new(project_path),
messages: Vec::new(),
}
}
pub fn from_messages(messages: Vec<Message>, project_path: Option<&Path>) -> Self {
let mut meta = SessionMetadata::new(project_path);
meta.message_count = messages.len();
Self {
metadata: meta,
messages,
}
}
pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
self.metadata.message_count = self.messages.len();
self.metadata.last_input_tokens = last_input_tokens as u64;
self.metadata.total_output_tokens = total_output_tokens;
self.metadata.updated_at = Utc::now();
}
}
pub struct SessionManager {
base_dir: PathBuf,
current_session: Option<Session>,
index: SessionIndex,
}
impl SessionManager {
pub fn new() -> Result<Self> {
let base_dir = Self::get_base_dir()?;
let manager = Self {
base_dir,
current_session: None,
index: SessionIndex::default(),
};
manager.ensure_dirs()?;
let mut manager = manager;
manager.load_index()?;
Ok(manager)
}
fn get_base_dir() -> Result<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| anyhow::anyhow!("HOME or USERPROFILE environment variable not set"))?;
let mut p = PathBuf::from(home);
p.push(".matrix");
Ok(p)
}
fn sessions_dir(&self) -> PathBuf {
self.base_dir.join("sessions")
}
fn index_path(&self) -> PathBuf {
self.sessions_dir().join("index.json")
}
fn session_path(&self, id: &str) -> PathBuf {
self.sessions_dir().join(format!("{}.json", id))
}
fn ensure_dirs(&self) -> Result<()> {
std::fs::create_dir_all(&self.base_dir)
.with_context(|| format!("creating base dir {}", self.base_dir.display()))?;
std::fs::create_dir_all(self.sessions_dir())
.with_context(|| format!("creating sessions dir {}", self.sessions_dir().display()))?;
Ok(())
}
fn load_index(&mut self) -> Result<()> {
let path = self.index_path();
if !path.exists() {
return Ok(());
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("reading index file {}", path.display()))?;
if data.trim().is_empty() {
return Ok(());
}
self.index = serde_json::from_str(&data)
.with_context(|| format!("parsing index file {}", path.display()))?;
Ok(())
}
fn save_index(&self) -> Result<()> {
let path = self.index_path();
let json =
serde_json::to_string_pretty(&self.index).context("serializing session index")?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)
.with_context(|| format!("writing index tmp file {}", tmp.display()))?;
std::fs::rename(&tmp, &path)
.with_context(|| format!("renaming index tmp file to {}", path.display()))?;
Ok(())
}
pub fn start_new(&mut self, project_path: Option<&Path>) -> Result<&Session> {
let session = Session::new(project_path);
self.current_session = Some(session);
self.save_current()?;
Ok(self.current_session.as_ref().unwrap())
}
pub fn continue_last(&mut self, project_path: Option<&Path>) -> Result<Option<&Session>> {
let last_id = self.index.last_session().map(|m| m.id.clone());
if let Some(id) = last_id {
self.load_session(&id)?;
if let Some(path) = project_path
&& let Some(ref mut session) = self.current_session
{
session.metadata.project_path = Some(path.to_string_lossy().to_string());
}
Ok(self.current_session.as_ref())
} else {
Ok(None)
}
}
pub fn resume(&mut self, query: &str, project_path: Option<&Path>) -> Result<Option<&Session>> {
let session_id = self.index.find(query).map(|m| m.id.clone());
if let Some(id) = session_id {
self.load_session(&id)?;
if let Some(path) = project_path
&& let Some(ref mut session) = self.current_session
{
session.metadata.project_path = Some(path.to_string_lossy().to_string());
}
Ok(self.current_session.as_ref())
} else {
Ok(None)
}
}
fn load_session(&mut self, id: &str) -> Result<()> {
let path = self.session_path(id);
if !path.exists() {
anyhow::bail!("session file {} not found", path.display());
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("reading session file {}", path.display()))?;
let mut session: Session = serde_json::from_str(&data)
.with_context(|| format!("parsing session file {}", path.display()))?;
if session.metadata.name.is_none()
&& let Some(index_meta) = self.index.find(id)
{
session.metadata.name = index_meta.name.clone();
}
self.current_session = Some(session);
Ok(())
}
pub fn save_current(&mut self) -> Result<()> {
if let Some(ref session) = self.current_session {
self.index.upsert(session.metadata.clone());
self.save_index()?;
let path = self.session_path(&session.metadata.id);
let json = serde_json::to_string(session).context("serializing session")?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)
.with_context(|| format!("writing session tmp file {}", tmp.display()))?;
std::fs::rename(&tmp, &path)
.with_context(|| format!("renaming session tmp file to {}", path.display()))?;
}
Ok(())
}
pub fn update_stats(&mut self, last_input_tokens: u32, total_output_tokens: u64) {
if let Some(ref mut session) = self.current_session {
session.update_stats(last_input_tokens, total_output_tokens);
}
}
pub fn record_compression(&mut self, entry: crate::compress::CompressionHistoryEntry) {
if let Some(ref mut session) = self.current_session {
session.metadata.add_compression_entry(entry);
}
}
pub fn set_messages(&mut self, messages: Vec<Message>) {
if let Some(ref mut session) = self.current_session {
if session.metadata.name.is_none()
&& !messages.is_empty()
&& let Some(name) = Self::generate_name_from_messages(&messages)
{
session.metadata.name = Some(name);
}
session.messages = messages;
session.metadata.message_count = session.messages.len();
session.metadata.updated_at = Utc::now();
}
}
fn generate_name_from_messages(messages: &[Message]) -> Option<String> {
use crate::providers::{ContentBlock, MessageContent, Role};
let user_messages: Vec<&Message> =
messages.iter().filter(|m| m.role == Role::User).collect();
for msg in user_messages.iter().take(3) {
let text = match &msg.content {
MessageContent::Text(t) => t.clone(),
MessageContent::Blocks(blocks) => blocks
.iter()
.filter_map(|b| {
if let ContentBlock::Text { text } = b {
Some(text.clone())
} else {
None
}
})
.collect::<Vec<_>>()
.join(" "),
};
let cleaned = text.trim().lines().next().unwrap_or("").trim();
if cleaned.len() < 5 || is_generic_message(cleaned) {
continue;
}
let name = if cleaned.chars().count() > 40 {
let truncated: String = cleaned.chars().take(37).collect();
format!("{}...", truncated)
} else {
cleaned.to_string()
};
return Some(name);
}
None
}
pub fn messages(&self) -> Option<&[Message]> {
self.current_session.as_ref().map(|s| s.messages.as_slice())
}
pub fn messages_mut(&mut self) -> Option<&mut Vec<Message>> {
self.current_session.as_mut().map(|s| &mut s.messages)
}
pub fn current_id(&self) -> Option<&str> {
self.current_session
.as_ref()
.map(|s| s.metadata.id.as_str())
}
pub fn current_name(&self) -> Option<&str> {
self.current_session.as_ref().and_then(|s| s.name())
}
pub fn rename_current(&mut self, new_name: &str) -> Result<()> {
if let Some(ref session) = self.current_session {
let id = session.metadata.id.clone();
self.index.rename(&id, new_name)?;
if let Some(ref mut session) = self.current_session {
session.metadata.name = Some(new_name.to_string());
}
self.save_current()?;
}
Ok(())
}
pub fn clear_current(&mut self) -> Result<()> {
if let Some(ref session) = self.current_session {
let path = self.session_path(&session.metadata.id);
let _ = std::fs::remove_file(&path);
self.index.remove(&session.metadata.id);
self.save_index()?;
}
self.current_session = None;
Ok(())
}
pub fn list_sessions(&self) -> &[SessionMetadata] {
&self.index.sessions
}
pub fn has_current(&self) -> bool {
self.current_session.is_some()
}
pub fn current_metadata(&self) -> Option<&SessionMetadata> {
self.current_session.as_ref().map(|s| &s.metadata)
}
pub fn history_path(&self) -> PathBuf {
self.base_dir.join("history.txt")
}
}
impl Session {
pub fn name(&self) -> Option<&str> {
self.metadata.name.as_deref()
}
}
use anyhow::Context;
fn is_generic_message(msg: &str) -> bool {
let generic = [
"继续", "好的", "ok", "yes", "no", "是", "否", "嗯", "对", "行", "可以", "好", "谢谢",
"thanks", "hi", "hello", "你好", "开始", "start",
];
generic.iter().any(|g| msg.eq_ignore_ascii_case(g))
}