Files
cassady/src/conversation.rs
T

281 lines
7.8 KiB
Rust

use anyhow::{Context, Result};
use chrono::{DateTime, Local, Utc};
use nanoid::nanoid;
use serde::{Deserialize, Serialize};
use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Record {
Meta {
chat_id: String,
created_at: String,
model: String,
cwd: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_chat_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
branch_from: Option<BranchPoint>,
},
System {
content: String,
},
User {
content: String,
ts: String,
},
Assistant {
content: String,
#[serde(default)]
reasoning: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_field: Option<String>,
tool_calls: Vec<StoredToolCall>,
ts: String,
},
Tool {
tool_call_id: String,
name: String,
ok: bool,
content: String,
ts: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BranchPoint {
pub chat_id: String,
pub record_index: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
pub checkpoint_label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StoredToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct Conversation {
pub id: String,
pub path: PathBuf,
pub records: Vec<Record>,
}
#[derive(Debug, Clone)]
pub struct ChatSummary {
pub id: String,
pub created_at: String,
pub model: String,
pub cwd: String,
pub first_user_preview: String,
}
pub fn new_chat_id() -> String {
let ts: DateTime<Local> = Local::now();
format!("{}-{}", ts.format("%Y-%m-%d-%H%M%S"), nanoid!(4))
}
pub fn now_ts() -> String {
Utc::now().to_rfc3339()
}
impl Conversation {
pub fn create(
conversations_dir: &Path,
model: &str,
cwd: &Path,
base_system: String,
) -> Result<Self> {
fs::create_dir_all(conversations_dir)?;
let id = new_chat_id();
let path = conversations_dir.join(format!("{id}.jsonl"));
let mut convo = Conversation {
id: id.clone(),
path,
records: Vec::new(),
};
convo.append(Record::Meta {
chat_id: id,
created_at: now_ts(),
model: model.to_string(),
cwd: cwd.display().to_string(),
parent_chat_id: None,
branch_from: None,
})?;
convo.append(Record::System {
content: base_system,
})?;
Ok(convo)
}
pub fn load(conversations_dir: &Path, id: &str) -> Result<(Self, Option<String>)> {
let path = conversations_dir.join(format!("{id}.jsonl"));
let file = File::open(&path).with_context(|| format!("opening {}", path.display()))?;
let reader = BufReader::new(file);
let mut records = Vec::new();
let mut warning = None;
for (idx, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Record>(&line) {
Ok(record) => records.push(record),
Err(err) => {
warning = Some(format!(
"Stopped loading at corrupted JSONL line {}: {}",
idx + 1,
err
));
break;
}
}
}
Ok((
Conversation {
id: id.to_string(),
path,
records,
},
warning,
))
}
pub fn append(&mut self, record: Record) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.with_context(|| format!("opening {} for append", self.path.display()))?;
let line = serde_json::to_string(&record)?;
writeln!(file, "{line}")?;
file.flush()?;
self.records.push(record);
Ok(())
}
pub fn base_system_prompt(&self) -> String {
self.records
.iter()
.find_map(|r| match r {
Record::System { content } => Some(content.clone()),
_ => None,
})
.unwrap_or_default()
}
pub fn meta_cwd(&self) -> Option<String> {
self.records.iter().find_map(|r| match r {
Record::Meta { cwd, .. } => Some(cwd.clone()),
_ => None,
})
}
pub fn meta(&self) -> Option<ConversationMeta> {
self.records.iter().find_map(|r| match r {
Record::Meta {
chat_id,
created_at,
model,
cwd,
parent_chat_id,
branch_from,
} => Some(ConversationMeta {
chat_id: chat_id.clone(),
created_at: created_at.clone(),
model: model.clone(),
cwd: cwd.clone(),
parent_chat_id: parent_chat_id.clone(),
branch_from: branch_from.clone(),
}),
_ => None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConversationMeta {
pub chat_id: String,
pub created_at: String,
pub model: String,
pub cwd: String,
pub parent_chat_id: Option<String>,
pub branch_from: Option<BranchPoint>,
}
pub fn list_chats(conversations_dir: &Path, cwd: &Path) -> Result<Vec<ChatSummary>> {
let mut out = Vec::new();
if !conversations_dir.exists() {
return Ok(out);
}
let cwd_str = cwd.display().to_string();
for entry in fs::read_dir(conversations_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(summary) = summarize_chat(stem, &path) {
if summary.cwd == cwd_str {
out.push(summary);
}
}
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
Ok(out)
}
fn summarize_chat(id: &str, path: &Path) -> Result<ChatSummary> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut created_at = String::new();
let mut model = String::new();
let mut cwd = String::new();
let mut first_user_preview = String::new();
for line in reader.lines().take(100) {
let line = line?;
let record: Record = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => break,
};
match record {
Record::Meta {
created_at: c,
model: m,
cwd: w,
..
} => {
created_at = c;
model = m;
cwd = w;
}
Record::User { content, .. } if first_user_preview.is_empty() => {
first_user_preview = content
.lines()
.next()
.unwrap_or("")
.chars()
.take(80)
.collect();
}
_ => {}
}
}
Ok(ChatSummary {
id: id.to_string(),
created_at,
model,
cwd,
first_user_preview,
})
}