crabtalk_core/runtime/
session.rs1use crate::model::Message;
11use serde::{Deserialize, Serialize};
12use std::{
13 fs::{self, OpenOptions},
14 io::{BufRead, BufReader, Write},
15 path::{Path, PathBuf},
16 time::Instant,
17};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SessionMeta {
22 pub agent: String,
23 pub created_by: String,
24 pub created_at: String,
25 #[serde(default)]
26 pub title: String,
27 #[serde(default)]
28 pub uptime_secs: u64,
29}
30
31#[derive(Serialize, Deserialize)]
33#[serde(untagged)]
34enum SessionLine {
35 Compact { compact: String },
36 Message(Message),
37}
38
39#[derive(Debug, Clone)]
41pub struct Session {
42 pub id: u64,
44 pub agent: String,
46 pub history: Vec<Message>,
48 pub created_by: String,
50 pub title: String,
52 pub uptime_secs: u64,
54 pub created_at: Instant,
56 pub file_path: Option<PathBuf>,
58}
59
60impl Session {
61 pub fn new(id: u64, agent: impl Into<String>, created_by: impl Into<String>) -> Self {
63 Self {
64 id,
65 agent: agent.into(),
66 history: Vec::new(),
67 created_by: created_by.into(),
68 title: String::new(),
69 uptime_secs: 0,
70 created_at: Instant::now(),
71 file_path: None,
72 }
73 }
74
75 pub fn init_file(&mut self, sessions_dir: &Path) {
80 let _ = fs::create_dir_all(sessions_dir);
81
82 let slug = sender_slug(&self.created_by);
83 let prefix = format!("{}_{slug}_", self.agent);
84 let seq = next_seq(sessions_dir, &prefix);
85 let filename = format!("{prefix}{seq}.jsonl");
86 let path = sessions_dir.join(filename);
87
88 let meta = SessionMeta {
89 agent: self.agent.clone(),
90 created_by: self.created_by.clone(),
91 created_at: chrono::Utc::now().to_rfc3339(),
92 title: String::new(),
93 uptime_secs: self.uptime_secs,
94 };
95
96 match OpenOptions::new()
97 .create(true)
98 .truncate(true)
99 .write(true)
100 .open(&path)
101 {
102 Ok(mut f) => {
103 if let Ok(json) = serde_json::to_string(&meta) {
104 let _ = writeln!(f, "{json}");
105 }
106 self.file_path = Some(path);
107 }
108 Err(e) => tracing::warn!("failed to create session file: {e}"),
109 }
110 }
111
112 pub fn set_title(&mut self, title: &str) {
114 self.title = title.to_string();
115
116 let Some(ref old_path) = self.file_path else {
117 return;
118 };
119
120 self.rewrite_meta();
122
123 let title_slug = sender_slug(title);
125 if title_slug.is_empty() {
126 return;
127 }
128 let old_name = old_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
129 let new_name = format!("{old_name}_{title_slug}.jsonl");
130 let new_path = old_path.with_file_name(new_name);
131 if fs::rename(old_path, &new_path).is_ok() {
132 self.file_path = Some(new_path);
133 }
134 }
135
136 pub fn rewrite_meta(&self) {
138 let Some(ref path) = self.file_path else {
139 return;
140 };
141 let Ok(content) = fs::read_to_string(path) else {
142 return;
143 };
144 let meta = SessionMeta {
145 agent: self.agent.clone(),
146 created_by: self.created_by.clone(),
147 created_at: chrono::Utc::now().to_rfc3339(),
148 title: self.title.clone(),
149 uptime_secs: self.uptime_secs,
150 };
151 let Ok(meta_json) = serde_json::to_string(&meta) else {
152 return;
153 };
154 let rest = content.find('\n').map(|i| &content[i..]).unwrap_or("");
156 let new_content = format!("{meta_json}{rest}");
157 let _ = fs::write(path, new_content);
158 }
159
160 pub fn append_messages(&self, messages: &[Message]) {
162 let Some(ref path) = self.file_path else {
163 return;
164 };
165 let mut file = match OpenOptions::new().append(true).open(path) {
166 Ok(f) => f,
167 Err(e) => {
168 tracing::warn!("failed to open session file for append: {e}");
169 return;
170 }
171 };
172 for msg in messages {
173 if msg.auto_injected {
174 continue;
175 }
176 if let Ok(json) = serde_json::to_string(msg) {
177 let _ = writeln!(file, "{json}");
178 }
179 }
180 }
181
182 pub fn append_compact(&self, summary: &str) {
184 let Some(ref path) = self.file_path else {
185 return;
186 };
187 let mut file = match OpenOptions::new().append(true).open(path) {
188 Ok(f) => f,
189 Err(e) => {
190 tracing::warn!("failed to open session file for compact: {e}");
191 return;
192 }
193 };
194 let line = SessionLine::Compact {
195 compact: summary.to_string(),
196 };
197 if let Ok(json) = serde_json::to_string(&line) {
198 let _ = writeln!(file, "{json}");
199 }
200 }
201
202 pub fn load_context(path: &Path) -> anyhow::Result<(SessionMeta, Vec<Message>)> {
207 let file = fs::File::open(path)?;
208 let reader = BufReader::new(file);
209 let mut lines = reader.lines();
210
211 let meta_line = lines
212 .next()
213 .ok_or_else(|| anyhow::anyhow!("empty session file"))??;
214 let meta: SessionMeta = serde_json::from_str(&meta_line)?;
215
216 let mut all_lines: Vec<String> = Vec::new();
217 let mut last_compact_idx: Option<usize> = None;
218
219 for line in lines {
220 let line = line?;
221 if line.trim().is_empty() {
222 continue;
223 }
224 if line.contains("\"compact\"")
225 && let Ok(SessionLine::Compact { .. }) = serde_json::from_str(&line)
226 {
227 last_compact_idx = Some(all_lines.len());
228 }
229 all_lines.push(line);
230 }
231
232 let context_start = last_compact_idx.unwrap_or_default();
233
234 let mut messages = Vec::new();
235 for (i, line) in all_lines[context_start..].iter().enumerate() {
236 if i == 0
237 && last_compact_idx.is_some()
238 && let Ok(SessionLine::Compact { compact }) = serde_json::from_str(line)
239 {
240 messages.push(Message::user(&compact));
241 continue;
242 }
243 if let Ok(msg) = serde_json::from_str::<Message>(line) {
244 messages.push(msg);
245 }
246 }
247
248 Ok((meta, messages))
249 }
250}
251
252pub fn find_latest_session(sessions_dir: &Path, agent: &str, created_by: &str) -> Option<PathBuf> {
257 let slug = sender_slug(created_by);
258 let prefix = format!("{agent}_{slug}_");
259
260 let mut best: Option<(u32, PathBuf)> = None;
261
262 for entry in fs::read_dir(sessions_dir).ok()?.flatten() {
263 let path = entry.path();
264 if path.is_dir() {
265 continue;
266 }
267 let name = path.file_name()?.to_str()?;
268 if !name.starts_with(&prefix) || !name.ends_with(".jsonl") {
269 continue;
270 }
271 let after_prefix = &name[prefix.len()..];
272 let seq_str = after_prefix.split(|c: char| !c.is_ascii_digit()).next()?;
273 let seq: u32 = seq_str.parse().ok()?;
274 if best.as_ref().is_none_or(|(best_seq, _)| seq > *best_seq) {
275 best = Some((seq, path));
276 }
277 }
278
279 best.map(|(_, path)| path)
280}
281
282fn next_seq(dir: &Path, prefix: &str) -> u32 {
284 let max = fs::read_dir(dir)
285 .ok()
286 .into_iter()
287 .flatten()
288 .flatten()
289 .filter_map(|e| {
290 let name = e.file_name();
291 let name = name.to_str()?;
292 if !name.starts_with(prefix) || !name.ends_with(".jsonl") {
293 return None;
294 }
295 let after_prefix = &name[prefix.len()..];
296 let seq_str = after_prefix.split(|c: char| !c.is_ascii_digit()).next()?;
297 seq_str.parse::<u32>().ok()
298 })
299 .max()
300 .unwrap_or(0);
301 max + 1
302}
303
304pub fn sender_slug(s: &str) -> String {
306 s.chars()
307 .map(|c| {
308 if c.is_alphanumeric() || c == '-' {
309 c.to_ascii_lowercase()
310 } else {
311 '-'
312 }
313 })
314 .collect::<String>()
315 .split('-')
316 .filter(|s| !s.is_empty())
317 .collect::<Vec<_>>()
318 .join("-")
319}