1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3use tokio::fs;
4use tokio::io::AsyncWriteExt;
5use tracing::warn;
6use uuid::Uuid;
7
8use crate::error::{AgentError, Result};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SessionInfo {
13 pub session_id: String,
15 pub summary: String,
17 pub last_modified: u64,
19 pub file_size: u64,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub custom_title: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub first_prompt: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub git_branch: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub cwd: Option<String>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SessionMessage {
38 #[serde(rename = "type")]
39 pub message_type: String,
40 pub uuid: String,
41 pub session_id: String,
42 pub message: serde_json::Value,
43 pub parent_tool_use_id: Option<String>,
44}
45
46#[derive(Debug)]
48pub struct Session {
49 pub id: String,
50 pub cwd: String,
51 pub messages: Vec<serde_json::Value>,
52 home_override: Option<PathBuf>,
54}
55
56impl Session {
57 pub fn new(cwd: impl Into<String>) -> Self {
59 Self {
60 id: Uuid::new_v4().to_string(),
61 cwd: cwd.into(),
62 messages: Vec::new(),
63 home_override: None,
64 }
65 }
66
67 pub fn with_id(id: impl Into<String>, cwd: impl Into<String>) -> Self {
69 Self {
70 id: id.into(),
71 cwd: cwd.into(),
72 messages: Vec::new(),
73 home_override: None,
74 }
75 }
76
77 pub fn with_home(mut self, home: impl Into<PathBuf>) -> Self {
79 self.home_override = Some(home.into());
80 self
81 }
82
83 pub fn sessions_dir(cwd: &str) -> PathBuf {
85 Self::sessions_dir_with_home(cwd, home_dir_or_tmp())
86 }
87
88 pub fn sessions_dir_with_home(cwd: &str, home: PathBuf) -> PathBuf {
90 let encoded_cwd = encode_path(cwd);
91 home.join(".claude").join("projects").join(encoded_cwd)
92 }
93
94 pub fn transcript_path(&self) -> PathBuf {
96 let home = self.home_override.clone().unwrap_or_else(home_dir_or_tmp);
97 Self::sessions_dir_with_home(&self.cwd, home)
98 .join(format!("{}.jsonl", self.id))
99 }
100
101 pub async fn append_message(&self, message: &serde_json::Value) -> Result<()> {
106 let path = self.transcript_path();
107
108 if let Some(parent) = path.parent() {
110 fs::create_dir_all(parent).await?;
111 }
112
113 let mut line = serde_json::to_string(message)?;
114 line.push('\n');
115
116 let mut file = fs::OpenOptions::new()
117 .create(true)
118 .append(true)
119 .open(&path)
120 .await?;
121
122 #[cfg(unix)]
124 {
125 use std::os::unix::fs::PermissionsExt;
126 let perms = std::fs::Permissions::from_mode(0o600);
127 fs::set_permissions(&path, perms).await?;
128 }
129
130 file.write_all(line.as_bytes()).await?;
131 file.flush().await?;
132
133 Ok(())
134 }
135
136 pub async fn load_messages(&self) -> Result<Vec<serde_json::Value>> {
141 let path = self.transcript_path();
142
143 if !path.exists() {
144 return Ok(Vec::new());
145 }
146
147 let contents = fs::read_to_string(&path).await?;
148 let mut messages = Vec::new();
149
150 for (i, line) in contents.lines().enumerate() {
151 let trimmed = line.trim();
152 if trimmed.is_empty() {
153 continue;
154 }
155 match serde_json::from_str::<serde_json::Value>(trimmed) {
156 Ok(value) => messages.push(value),
157 Err(e) => {
158 warn!(
159 "Skipping malformed JSON on line {} of {}: {}",
160 i + 1,
161 path.display(),
162 e
163 );
164 }
165 }
166 }
167
168 Ok(messages)
169 }
170}
171
172pub async fn list_sessions(
181 dir: Option<&str>,
182 limit: Option<usize>,
183) -> Result<Vec<SessionInfo>> {
184 list_sessions_with_home(dir, limit, home_dir_or_tmp()).await
185}
186
187pub async fn list_sessions_with_home(
189 dir: Option<&str>,
190 limit: Option<usize>,
191 home: PathBuf,
192) -> Result<Vec<SessionInfo>> {
193 let cwd = resolve_cwd(dir)?;
194 let sessions_dir = Session::sessions_dir_with_home(&cwd, home);
195
196 if !sessions_dir.exists() {
197 return Ok(Vec::new());
198 }
199
200 let mut entries = fs::read_dir(&sessions_dir).await?;
201 let mut infos: Vec<SessionInfo> = Vec::new();
202
203 while let Some(entry) = entries.next_entry().await? {
204 let path = entry.path();
205
206 let ext = path.extension().and_then(|e| e.to_str());
208 if ext != Some("jsonl") {
209 continue;
210 }
211
212 let session_id = match path.file_stem().and_then(|s| s.to_str()) {
213 Some(stem) => stem.to_string(),
214 None => continue,
215 };
216
217 let metadata = match fs::metadata(&path).await {
218 Ok(m) => m,
219 Err(_) => continue,
220 };
221
222 let last_modified = metadata
223 .modified()
224 .ok()
225 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
226 .map(|d| d.as_millis() as u64)
227 .unwrap_or(0);
228
229 let file_size = metadata.len();
230
231 let (first_prompt, custom_title) = extract_session_metadata(&path).await;
233
234 let summary = custom_title
235 .clone()
236 .or_else(|| first_prompt.clone())
237 .unwrap_or_else(|| "(empty session)".to_string());
238
239 infos.push(SessionInfo {
240 session_id,
241 summary,
242 last_modified,
243 file_size,
244 custom_title,
245 first_prompt,
246 git_branch: None,
247 cwd: Some(cwd.clone()),
248 });
249 }
250
251 infos.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
253
254 if let Some(limit) = limit {
255 infos.truncate(limit);
256 }
257
258 Ok(infos)
259}
260
261pub async fn get_session_messages(
268 session_id: &str,
269 dir: Option<&str>,
270 limit: Option<usize>,
271 offset: Option<usize>,
272) -> Result<Vec<SessionMessage>> {
273 get_session_messages_with_home(session_id, dir, limit, offset, home_dir_or_tmp()).await
274}
275
276pub async fn get_session_messages_with_home(
278 session_id: &str,
279 dir: Option<&str>,
280 limit: Option<usize>,
281 offset: Option<usize>,
282 home: PathBuf,
283) -> Result<Vec<SessionMessage>> {
284 let cwd = resolve_cwd(dir)?;
285 let session = Session::with_id(session_id, &cwd).with_home(&home);
286 let path = session.transcript_path();
287
288 if !path.exists() {
289 return Err(AgentError::SessionNotFound(session_id.to_string()));
290 }
291
292 let contents = fs::read_to_string(&path).await?;
293 let offset = offset.unwrap_or(0);
294
295 let mut messages: Vec<SessionMessage> = Vec::new();
296
297 for (i, line) in contents.lines().enumerate() {
298 let trimmed = line.trim();
299 if trimmed.is_empty() {
300 continue;
301 }
302
303 if i < offset {
305 continue;
306 }
307
308 if let Some(limit) = limit {
310 if messages.len() >= limit {
311 break;
312 }
313 }
314
315 match serde_json::from_str::<serde_json::Value>(trimmed) {
316 Ok(value) => {
317 let msg = SessionMessage {
318 message_type: value
319 .get("type")
320 .and_then(|v| v.as_str())
321 .unwrap_or("unknown")
322 .to_string(),
323 uuid: value
324 .get("uuid")
325 .and_then(|v| v.as_str())
326 .unwrap_or("")
327 .to_string(),
328 session_id: value
329 .get("session_id")
330 .and_then(|v| v.as_str())
331 .unwrap_or(session_id)
332 .to_string(),
333 message: value,
334 parent_tool_use_id: None,
335 };
336 messages.push(msg);
337 }
338 Err(e) => {
339 warn!(
340 "Skipping malformed JSON on line {} of {}: {}",
341 i + 1,
342 path.display(),
343 e
344 );
345 }
346 }
347 }
348
349 Ok(messages)
350}
351
352pub async fn find_most_recent_session(dir: Option<&str>) -> Result<Option<SessionInfo>> {
357 let sessions = list_sessions(dir, Some(1)).await?;
358 Ok(sessions.into_iter().next())
359}
360
361pub async fn find_most_recent_session_with_home(dir: Option<&str>, home: PathBuf) -> Result<Option<SessionInfo>> {
363 let sessions = list_sessions_with_home(dir, Some(1), home).await?;
364 Ok(sessions.into_iter().next())
365}
366
367fn encode_path(path: &str) -> String {
374 path.chars()
375 .map(|c| if c.is_alphanumeric() { c } else { '-' })
376 .collect()
377}
378
379fn home_dir_or_tmp() -> PathBuf {
381 std::env::var("HOME")
382 .ok()
383 .map(PathBuf::from)
384 .unwrap_or_else(|| PathBuf::from("/tmp"))
385}
386
387fn resolve_cwd(dir: Option<&str>) -> Result<String> {
390 match dir {
391 Some(d) => Ok(d.to_string()),
392 None => std::env::current_dir()
393 .map(|p| p.to_string_lossy().into_owned())
394 .map_err(|e| AgentError::Io(e)),
395 }
396}
397
398async fn extract_session_metadata(path: &PathBuf) -> (Option<String>, Option<String>) {
403 let contents = match fs::read_to_string(path).await {
404 Ok(c) => c,
405 Err(_) => return (None, None),
406 };
407
408 let mut first_prompt: Option<String> = None;
409 let mut custom_title: Option<String> = None;
410
411 for line in contents.lines().take(50) {
412 let trimmed = line.trim();
413 if trimmed.is_empty() {
414 continue;
415 }
416
417 let value: serde_json::Value = match serde_json::from_str(trimmed) {
418 Ok(v) => v,
419 Err(_) => continue,
420 };
421
422 if let Some(title) = value.get("customTitle").and_then(|v| v.as_str()) {
424 if !title.is_empty() {
425 custom_title = Some(title.to_string());
426 }
427 }
428 if let Some(title) = value.get("custom_title").and_then(|v| v.as_str()) {
429 if !title.is_empty() {
430 custom_title = Some(title.to_string());
431 }
432 }
433
434 if first_prompt.is_none() {
436 if let Some("user") = value.get("type").and_then(|v| v.as_str()) {
437 if let Some(content) = value.get("content") {
438 let text = extract_text_from_content(content);
439 if !text.is_empty() {
440 let truncated = if text.len() > 200 {
442 format!("{}...", &text[..200])
443 } else {
444 text
445 };
446 first_prompt = Some(truncated);
447 }
448 }
449 }
450 }
451
452 if first_prompt.is_some() && custom_title.is_some() {
454 break;
455 }
456 }
457
458 (first_prompt, custom_title)
459}
460
461fn extract_text_from_content(content: &serde_json::Value) -> String {
466 if let Some(s) = content.as_str() {
467 return s.to_string();
468 }
469
470 if let Some(blocks) = content.as_array() {
471 let texts: Vec<&str> = blocks
472 .iter()
473 .filter_map(|block| {
474 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
475 block.get("text").and_then(|t| t.as_str())
476 } else {
477 None
478 }
479 })
480 .collect();
481 return texts.join(" ");
482 }
483
484 String::new()
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use serde_json::json;
491 use tempfile::TempDir;
492
493 fn session_in_tmp(tmp: &TempDir) -> Session {
496 Session::new("/test/project").with_home(tmp.path())
497 }
498
499 #[tokio::test]
500 async fn test_append_and_load_roundtrip() {
501 let tmp = TempDir::new().unwrap();
502 let session = session_in_tmp(&tmp);
503
504 let msg1 = json!({"type": "user", "content": "hello"});
505 let msg2 = json!({"type": "assistant", "content": "world"});
506
507 session.append_message(&msg1).await.unwrap();
508 session.append_message(&msg2).await.unwrap();
509
510 let loaded = session.load_messages().await.unwrap();
511 assert_eq!(loaded.len(), 2);
512 assert_eq!(loaded[0]["content"], "hello");
513 assert_eq!(loaded[1]["content"], "world");
514 }
515
516 #[tokio::test]
517 async fn test_load_messages_empty_file() {
518 let tmp = TempDir::new().unwrap();
519 let session = session_in_tmp(&tmp);
520
521 let loaded = session.load_messages().await.unwrap();
523 assert!(loaded.is_empty());
524 }
525
526 #[tokio::test]
527 async fn test_transcript_path_encoding() {
528 let session = Session::with_id("abc-123", "/home/user/my project");
529 let path = session.transcript_path();
530 let path_str = path.to_string_lossy();
531
532 assert!(path_str.contains("-home-user-my-project"));
534 assert!(path_str.ends_with("abc-123.jsonl"));
535 }
536
537 #[tokio::test]
538 async fn test_list_sessions_and_find_most_recent() {
539 let tmp = TempDir::new().unwrap();
540 let home = tmp.path().to_path_buf();
541
542 let cwd = "/test/project";
543
544 let s1 = Session::with_id("session-1", cwd).with_home(&home);
546 let s2 = Session::with_id("session-2", cwd).with_home(&home);
547
548 s1.append_message(&json!({"type": "user", "content": [{"type": "text", "text": "first prompt"}]}))
549 .await
550 .unwrap();
551
552 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
554
555 s2.append_message(&json!({"type": "user", "content": "second session prompt"}))
556 .await
557 .unwrap();
558
559 let sessions = list_sessions_with_home(Some(cwd), None, home.clone()).await.unwrap();
560 assert_eq!(sessions.len(), 2);
561
562 assert_eq!(sessions[0].session_id, "session-2");
564 assert_eq!(sessions[1].session_id, "session-1");
565
566 let sessions = list_sessions_with_home(Some(cwd), Some(1), home.clone()).await.unwrap();
568 assert_eq!(sessions.len(), 1);
569 assert_eq!(sessions[0].session_id, "session-2");
570
571 let recent = find_most_recent_session_with_home(Some(cwd), home.clone()).await.unwrap();
573 assert!(recent.is_some());
574 assert_eq!(recent.unwrap().session_id, "session-2");
575 }
576
577 #[tokio::test]
578 async fn test_get_session_messages_pagination() {
579 let tmp = TempDir::new().unwrap();
580 let home = tmp.path().to_path_buf();
581
582 let cwd = "/test/project";
583 let session = Session::with_id("paginated", cwd).with_home(&home);
584
585 for i in 0..10 {
586 session
587 .append_message(&json!({"type": "user", "content": format!("msg {}", i)}))
588 .await
589 .unwrap();
590 }
591
592 let all = get_session_messages_with_home("paginated", Some(cwd), None, None, home.clone())
594 .await
595 .unwrap();
596 assert_eq!(all.len(), 10);
597
598 let page = get_session_messages_with_home("paginated", Some(cwd), Some(3), Some(2), home.clone())
600 .await
601 .unwrap();
602 assert_eq!(page.len(), 3);
603 assert_eq!(page[0].message["content"], "msg 2");
604
605 let err = get_session_messages_with_home("nonexistent", Some(cwd), None, None, home.clone()).await;
607 assert!(err.is_err());
608 }
609}