use std::cmp::Reverse;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationTurn {
pub turn_index: usize,
pub timestamp: i64,
pub user_message: String,
pub agent_response: String,
pub tool_summary: String,
}
pub struct Session {
pub id: String,
pub _cwd: String,
pub _turns: Vec<ConversationTurn>,
pub active_turn: usize,
pub path: PathBuf,
}
pub fn sessions_dir() -> PathBuf {
std::env::var("XDG_DATA_HOME")
.ok()
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default())
.join(".local/share")
})
.join("parecode/sessions")
}
fn cwd_basename(cwd: &str) -> &str {
Path::new(cwd)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
}
pub fn open_session(cwd: &str) -> Result<Session> {
let dir = sessions_dir();
std::fs::create_dir_all(&dir)?;
let ts = chrono::Utc::now().timestamp();
let basename = cwd_basename(cwd);
let id = format!("{ts}_{basename}");
let path = dir.join(format!("{id}.jsonl"));
let _ = std::fs::OpenOptions::new().create(true).append(true).open(&path);
Ok(Session {
id,
_cwd: cwd.to_string(),
_turns: Vec::new(),
active_turn: 0,
path,
})
}
pub fn append_turn(path: &Path, turn: &ConversationTurn) -> Result<()> {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let line = serde_json::to_string(turn)?;
writeln!(f, "{line}")?;
Ok(())
}
pub fn load_session_turns(path: &Path) -> Result<Vec<ConversationTurn>> {
let content = std::fs::read_to_string(path)?;
let turns = content
.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str::<ConversationTurn>(l))
.collect::<Result<Vec<_>, _>>()?;
Ok(turns)
}
pub fn list_sessions() -> Result<Vec<(String, PathBuf)>> {
let dir = sessions_dir();
if !dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<_> = std::fs::read_dir(&dir)?
.flatten()
.filter(|e| {
e.path().extension().map(|x| x == "jsonl").unwrap_or(false)
})
.collect();
entries.sort_by_key(|e| Reverse(e.file_name()));
Ok(entries
.iter()
.map(|e| {
let name = e
.file_name()
.to_string_lossy()
.trim_end_matches(".jsonl")
.to_string();
(name, e.path())
})
.collect())
}
pub fn prune_old_sessions(keep: usize) {
let Ok(sessions) = list_sessions() else { return };
let mut kept = 0usize;
for (_, path) in sessions {
let is_empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(true);
if is_empty {
let _ = std::fs::remove_file(&path);
} else {
kept += 1;
if kept > keep {
let _ = std::fs::remove_file(&path);
}
}
}
}
pub fn find_latest_for_cwd(cwd: &str) -> Option<(String, PathBuf)> {
let suffix = format!("_{}", cwd_basename(cwd));
list_sessions().ok()?.into_iter().find(|(id, _)| id.ends_with(&suffix))
}
const PRIOR_CONTEXT_TOKEN_CAP: usize = 8000;
pub fn build_prior_context(turns: &[ConversationTurn]) -> Option<String> {
if turns.is_empty() {
return None;
}
let char_budget = PRIOR_CONTEXT_TOKEN_CAP * 4;
let mut used = 0usize;
let mut parts: Vec<String> = Vec::new();
for turn in turns.iter().rev() {
let response_preview = truncate_str(&turn.agent_response, 2000);
let user_preview = truncate_str(&turn.user_message, 500);
let entry = if turn.tool_summary.is_empty() {
format!(
"[Turn {}]\nUser: {}\nAssistant: {}\n",
turn.turn_index + 1,
user_preview,
response_preview,
)
} else {
let actions: Vec<&str> = turn.tool_summary.split(", ").collect();
let mut modified: Vec<&str> = Vec::new();
let mut other: Vec<&str> = Vec::new();
for a in &actions {
if a.starts_with("edit_file") || a.starts_with("write_file") {
modified.push(a);
} else {
other.push(a);
}
}
let mut lines = vec![format!("[Turn {}]", turn.turn_index + 1)];
lines.push(format!("User: {}", user_preview));
if !modified.is_empty() {
let mut seen = std::collections::HashSet::new();
let deduped: Vec<&str> = modified.iter().copied()
.filter(|a| seen.insert(*a))
.collect();
lines.push(format!("Files modified: {}", deduped.join(", ")));
}
if !other.is_empty() {
lines.push(format!("Tools used: {}", other.join(", ")));
}
lines.push(format!("Assistant: {}", response_preview));
format!("{}\n", lines.join("\n"))
};
if used + entry.len() > char_budget {
break;
}
used += entry.len();
parts.push(entry);
}
if parts.is_empty() {
return None;
}
parts.reverse();
Some(format!(
"# Conversation history (this session)\nNote: short user replies (e.g. \"yes\", \"ok\", \"go ahead\") are responses to the previous assistant message.\n\n{}\n---\n\n",
parts.join("\n")
))
}
fn truncate_str(s: &str, max_chars: usize) -> &str {
if s.len() <= max_chars {
s
} else {
let mut end = max_chars;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}