1use serde::{Serialize, Deserialize};
2use serde_json::Value;
3use std::path::PathBuf;
4use chrono::{DateTime, Utc};
5
6
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct Session {
10 pub id: String,
11 pub title: String,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
13 pub name: Option<String>,
14 pub model: String,
15 pub thinking_level: String,
16 pub system_prompt: Option<String>,
17 pub created_at: DateTime<Utc>,
18 pub updated_at: DateTime<Utc>,
19 pub total_input_tokens: u64,
20 pub total_output_tokens: u64,
21 pub session_cost: f64,
22 pub api_messages: Vec<Value>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub abort_context: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub parent_session: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub compacted_into: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SessionInfo {
37 pub id: String,
38 pub title: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub name: Option<String>,
41 pub model: String,
42 pub created_at: DateTime<Utc>,
43 pub updated_at: DateTime<Utc>,
44 pub session_cost: f64,
45 pub message_count: usize,
46}
47
48impl Session {
49 pub fn new(model: &str, thinking_level: &str, system_prompt: Option<&str>) -> Self {
50 let now = Utc::now();
51 let id = format!("{}-{}", now.format("%Y%m%d-%H%M%S"), &uuid::Uuid::new_v4().to_string()[..4]);
52 Session {
53 id,
54 title: String::new(),
55 name: None,
56 model: model.to_string(),
57 thinking_level: thinking_level.to_string(),
58 system_prompt: system_prompt.map(|s| s.to_string()),
59 created_at: now,
60 updated_at: now,
61 total_input_tokens: 0,
62 total_output_tokens: 0,
63 session_cost: 0.0,
64 api_messages: Vec::new(),
65 abort_context: None,
66 parent_session: None,
67 compacted_into: None,
68 }
69 }
70
71 pub fn new_from_compaction(parent: &Session, summary: String) -> Self {
73 let now = Utc::now();
74 let id = format!("{}-{}", now.format("%Y%m%d-%H%M%S"), &uuid::Uuid::new_v4().to_string()[..4]);
75 let name = parent.name.clone();
79 let mut summary_parts = String::new();
80 if let Some(ref sp) = parent.system_prompt {
81 summary_parts.push_str(&format!("<system-prompt>\n{}\n</system-prompt>\n\n", sp));
82 }
83 summary_parts.push_str(&format!(
84 "The conversation history before this point was compacted into the following summary:\n\n<context-summary>\n{}\n</context-summary>\n\nContinue from where we left off. The summary and system prompt above contain all the context you need.",
85 summary
86 ));
87 Session {
88 id,
89 title: format!("↳ {}", if parent.title.is_empty() { &parent.id } else { &parent.title }),
90 name,
91 model: parent.model.clone(),
92 thinking_level: parent.thinking_level.clone(),
93 system_prompt: parent.system_prompt.clone(),
94 created_at: now,
95 updated_at: now,
96 total_input_tokens: 0,
97 total_output_tokens: 0,
98 session_cost: 0.0,
99 api_messages: vec![
100 serde_json::json!({"role": "user", "content": summary_parts}),
101 serde_json::json!({"role": "assistant", "content": "I've loaded the conversation summary and system prompt. Ready to continue."}),
102 ],
103 abort_context: None,
104 parent_session: Some(parent.id.clone()),
105 compacted_into: None,
106 }
107 }
108
109 pub fn auto_title(&mut self) {
111 if !self.title.is_empty() {
112 return;
113 }
114 for msg in &self.api_messages {
115 if msg["role"].as_str() == Some("user") {
116 if let Some(content) = msg["content"].as_str() {
117 self.title = content.chars().take(80).collect();
118 return;
119 }
120 }
121 }
122 }
123
124 pub async fn save(&self) -> std::io::Result<()> {
125 let dir = crate::config::resolve_write_path("sessions");
126 tokio::fs::create_dir_all(&dir).await?;
127 let path = dir.join(format!("{}.json", self.id));
128 let tmp = path.with_extension("tmp");
129 let json = serde_json::to_string(self)
130 .map_err(std::io::Error::other)?;
131 tokio::fs::write(&tmp, &json).await?;
132 tokio::fs::rename(&tmp, &path).await
133 }
134
135 pub fn load(id: &str) -> std::io::Result<Self> {
136 let path = sessions_dir().join(format!("{}.json", id));
137 let content = std::fs::read_to_string(path)?;
138 serde_json::from_str(&content)
139 .map_err(std::io::Error::other)
140 }
141
142 pub fn info(&self) -> SessionInfo {
143 SessionInfo {
144 id: self.id.clone(),
145 title: self.title.clone(),
146 name: self.name.clone(),
147 model: self.model.clone(),
148 created_at: self.created_at,
149 updated_at: self.updated_at,
150 session_cost: self.session_cost,
151 message_count: self.api_messages.len(),
152 }
153 }
154
155 pub fn set_name(&mut self, name: &str) -> std::io::Result<()> {
159 validate_name(name).map_err(std::io::Error::other)?;
160 if self.name.as_deref() == Some(name) {
161 return Ok(());
162 }
163 let sessions = list_sessions()?;
164 for s in &sessions {
165 if s.name.as_deref() == Some(name) && s.id != self.id {
166 return Err(std::io::Error::other(format!(
167 "name '{}' already used by session {}",
168 name, s.id
169 )));
170 }
171 }
172 if crate::core::chain::load_chain(name).is_ok() {
173 return Err(std::io::Error::other(format!(
174 "name '{}' conflicts with an existing chain name",
175 name
176 )));
177 }
178 self.name = Some(name.to_string());
179 Ok(())
180 }
181
182 pub fn clear_name(&mut self) {
183 self.name = None;
184 }
185}
186
187pub fn find_session(partial_id: &str) -> std::io::Result<Session> {
189 let dir = sessions_dir();
190 if !dir.exists() {
191 return Err(std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions directory"));
192 }
193
194 let exact = dir.join(format!("{}.json", partial_id));
196 if exact.exists() {
197 return Session::load(partial_id);
198 }
199
200 let mut matches: Vec<String> = Vec::new();
202 for entry in std::fs::read_dir(&dir)? {
203 let entry = entry?;
204 let name = entry.file_name().to_string_lossy().to_string();
205 if name.ends_with(".json") {
206 let id = name.trim_end_matches(".json");
207 if id.contains(partial_id) {
208 matches.push(id.to_string());
209 }
210 }
211 }
212
213 match matches.len() {
214 0 => Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("no session matching '{}'", partial_id))),
215 1 => Session::load(&matches[0]),
216 _ => Err(std::io::Error::other(format!("ambiguous: {} sessions match '{}'", matches.len(), partial_id))),
217 }
218}
219
220pub fn latest_session() -> std::io::Result<Session> {
222 let dir = sessions_dir();
229 if !dir.exists() {
230 return Err(std::io::Error::new(
231 std::io::ErrorKind::NotFound,
232 "no sessions found",
233 ));
234 }
235 let mut newest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
236 for entry in std::fs::read_dir(&dir)? {
237 let entry = entry?;
238 let path = entry.path();
239 if path.extension().is_some_and(|e| e == "json") {
240 if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
241 if newest.as_ref().map_or(true, |(t, _)| mtime > *t) {
242 newest = Some((mtime, path));
243 }
244 }
245 }
246 }
247 let path = newest.map(|(_, p)| p).ok_or_else(|| {
248 std::io::Error::new(std::io::ErrorKind::NotFound, "no sessions found")
249 })?;
250 let id = path
251 .file_stem()
252 .and_then(|s| s.to_str())
253 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "bad session filename"))?;
254 Session::load(id)
255}
256
257pub fn list_sessions() -> std::io::Result<Vec<SessionInfo>> {
264 let dir = sessions_dir();
265 if !dir.exists() {
266 return Ok(Vec::new());
267 }
268 let mut sessions: Vec<SessionInfo> = Vec::new();
269 for entry in std::fs::read_dir(&dir)? {
270 let entry = entry?;
271 let path = entry.path();
272 if path.extension().is_some_and(|e| e == "json") {
273 if let Some(info) = parse_session_header(&path) {
274 sessions.push(info);
275 }
276 }
277 }
278 sessions.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
279 Ok(sessions)
280}
281
282pub fn list_recent_sessions(limit: usize) -> std::io::Result<Vec<SessionInfo>> {
288 let dir = sessions_dir();
289 if !dir.exists() {
290 return Ok(Vec::new());
291 }
292 let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
293 for entry in std::fs::read_dir(&dir)? {
294 let entry = entry?;
295 let path = entry.path();
296 if path.extension().is_some_and(|e| e == "json") {
297 if let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) {
298 files.push((mtime, path));
299 }
300 }
301 }
302 files.sort_by_key(|(t, _)| std::cmp::Reverse(*t));
303 files.truncate(limit);
304 let mut sessions: Vec<SessionInfo> = Vec::new();
305 for (_, path) in files {
306 if let Some(info) = parse_session_header(&path) {
307 sessions.push(info);
308 }
309 }
310 Ok(sessions)
311}
312
313fn parse_session_header(path: &std::path::Path) -> Option<SessionInfo> {
317 #[derive(Deserialize)]
318 struct SessionMetadata {
319 id: String,
320 #[serde(default)]
321 title: String,
322 #[serde(default)]
323 name: Option<String>,
324 model: String,
325 created_at: DateTime<Utc>,
326 updated_at: DateTime<Utc>,
327 #[serde(default)]
328 session_cost: f64,
329 }
330 let header = read_session_header(path)?;
331 let meta: SessionMetadata = serde_json::from_str(&header).ok()?;
332 Some(SessionInfo {
333 id: meta.id,
334 title: meta.title,
335 name: meta.name,
336 model: meta.model,
337 created_at: meta.created_at,
338 updated_at: meta.updated_at,
339 session_cost: meta.session_cost,
340 message_count: 0,
341 })
342}
343
344fn read_session_header(path: &std::path::Path) -> Option<String> {
352 use std::io::Read;
353 const KEY: &[u8] = b"\"api_messages\"";
354 const MAX_HEADER: usize = 256 * 1024; let mut file = std::fs::File::open(path).ok()?;
357 let mut buf: Vec<u8> = Vec::with_capacity(64 * 1024);
358 let mut chunk = [0u8; 16 * 1024];
359 let mut cut: Option<usize> = None;
360 while buf.len() <= MAX_HEADER {
361 let n = file.read(&mut chunk).ok()?;
362 if n == 0 {
363 break;
364 }
365 buf.extend_from_slice(&chunk[..n]);
366 if let Some(pos) = buf.windows(KEY.len()).position(|w| w == KEY) {
367 cut = Some(pos);
368 break;
369 }
370 }
371
372 let end = cut.unwrap_or(buf.len());
373 let trimmed = String::from_utf8_lossy(&buf[..end]);
374 let mut s = trimmed.trim_end().to_string();
375 if s.ends_with(',') {
376 s.pop();
377 }
378 if !s.ends_with('}') {
379 s.push('}');
380 }
381 Some(s)
382}
383
384fn sessions_dir() -> PathBuf {
385 crate::config::get_active_config_dir().join("sessions")
386}
387
388pub fn validate_name(name: &str) -> Result<(), String> {
390 if name.is_empty() {
391 return Err("name cannot be empty".into());
392 }
393 if name.len() > 40 {
394 return Err(format!("invalid name '{}': must be 40 chars or less", name));
395 }
396 if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
397 return Err(format!(
398 "invalid name '{}': allowed characters are lowercase letters, digits, and '-'",
399 name
400 ));
401 }
402 Ok(())
403}
404
405pub fn find_session_by_name(name: &str) -> std::io::Result<Session> {
409 let dir = sessions_dir();
410 if dir.exists() {
411 for entry in std::fs::read_dir(&dir)? {
412 let entry = entry?;
413 let path = entry.path();
414 if !path.extension().is_some_and(|e| e == "json") {
415 continue;
416 }
417 if let Some(info) = parse_session_header(&path) {
418 if info.name.as_deref() == Some(name) {
419 return Session::load(&info.id);
420 }
421 }
422 }
423 }
424 Err(std::io::Error::new(
425 std::io::ErrorKind::NotFound,
426 format!("no session named '{}'", name),
427 ))
428}
429
430pub fn resolve_session(query: &str) -> std::io::Result<Session> {
433 if let Ok(ptr) = crate::core::chain::load_chain(query) {
434 match Session::load(&ptr.head) {
435 Ok(s) => {
436 tracing::info!("resolved '{}' via chain → session {}", query, ptr.head);
437 return Ok(s);
438 }
439 Err(e) => {
440 return Err(std::io::Error::new(
441 e.kind(),
442 format!(
443 "chain '{}' points to session '{}' which failed to load: {} (try /chain unname {})",
444 query, ptr.head, e, query
445 ),
446 ));
447 }
448 }
449 }
450 if let Ok(s) = find_session_by_name(query) {
451 tracing::info!("resolved '{}' via session name → {}", query, s.id);
452 return Ok(s);
453 }
454 find_session(query)
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use serde_json::json;
461
462 #[test]
463 fn test_session_new() {
464 let session = Session::new("gpt-4", "brief", Some("test prompt"));
465
466 assert_eq!(session.model, "gpt-4");
468 assert_eq!(session.thinking_level, "brief");
469 assert_eq!(session.system_prompt, Some("test prompt".to_string()));
470
471 assert!(!session.id.is_empty());
473
474 assert_eq!(session.title, "");
476
477 assert_eq!(session.total_input_tokens, 0);
479 assert_eq!(session.total_output_tokens, 0);
480
481 assert_eq!(session.session_cost, 0.0);
483
484 assert!(session.api_messages.is_empty());
486
487 let session_no_prompt = Session::new("gpt-3.5-turbo", "normal", None);
489 assert_eq!(session_no_prompt.model, "gpt-3.5-turbo");
490 assert_eq!(session_no_prompt.thinking_level, "normal");
491 assert_eq!(session_no_prompt.system_prompt, None);
492 }
493
494 #[test]
495 fn test_session_auto_title() {
496 let mut session = Session::new("gpt-4", "brief", None);
497
498 session.api_messages.push(json!({
500 "role": "user",
501 "content": "hello world"
502 }));
503
504 session.auto_title();
506
507 assert_eq!(session.title, "hello world");
509
510 session.title = "existing title".to_string();
512 session.auto_title();
513 assert_eq!(session.title, "existing title");
514
515 let mut empty_session = Session::new("gpt-4", "brief", None);
517 empty_session.auto_title();
518 assert_eq!(empty_session.title, "");
519
520 let mut session_no_user = Session::new("gpt-4", "brief", None);
522 session_no_user.api_messages.push(json!({
523 "role": "assistant",
524 "content": "response"
525 }));
526 session_no_user.auto_title();
527 assert_eq!(session_no_user.title, "");
528
529 let mut session_long = Session::new("gpt-4", "brief", None);
531 let long_content = "a".repeat(100);
532 session_long.api_messages.push(json!({
533 "role": "user",
534 "content": long_content
535 }));
536 session_long.auto_title();
537 assert_eq!(session_long.title.len(), 80);
538 assert_eq!(session_long.title, "a".repeat(80));
539 }
540
541 #[test]
542 fn test_session_info() {
543 let mut session = Session::new("gpt-4", "brief", Some("system prompt"));
544
545 session.api_messages.push(json!({
547 "role": "user",
548 "content": "test message"
549 }));
550 session.api_messages.push(json!({
551 "role": "assistant",
552 "content": "test response"
553 }));
554
555 session.title = "Test Title".to_string();
556 session.session_cost = 0.05;
557
558 let info = session.info();
559
560 assert_eq!(info.id, session.id);
561 assert_eq!(info.title, "Test Title");
562 assert_eq!(info.model, "gpt-4");
563 assert_eq!(info.created_at, session.created_at);
564 assert_eq!(info.updated_at, session.updated_at);
565 assert_eq!(info.session_cost, 0.05);
566 assert_eq!(info.message_count, 2);
567 }
568
569 #[test]
570 fn test_session_info_struct() {
571 let now = Utc::now();
572
573 let session_info = SessionInfo {
574 id: "test-id".to_string(),
575 title: "Test Title".to_string(),
576 name: None,
577 model: "gpt-4".to_string(),
578 created_at: now,
579 updated_at: now,
580 session_cost: 1.23,
581 message_count: 5,
582 };
583
584 assert_eq!(session_info.id, "test-id");
586 assert_eq!(session_info.title, "Test Title");
587 assert_eq!(session_info.model, "gpt-4");
588 assert_eq!(session_info.created_at, now);
589 assert_eq!(session_info.updated_at, now);
590 assert_eq!(session_info.session_cost, 1.23);
591 assert_eq!(session_info.message_count, 5);
592 }
593
594 #[test]
595 fn test_session_serialization_round_trip() {
596 let mut session = Session::new("gpt-4-turbo", "detailed", Some("You are a helpful assistant"));
597 session.title = "Test Session".to_string();
598 session.api_messages.push(json!({"role": "user", "content": "test"}));
599 session.total_input_tokens = 100;
600 session.total_output_tokens = 200;
601 session.session_cost = 0.15;
602
603 let json_str = serde_json::to_string(&session).expect("Failed to serialize session");
605
606 let deserialized: Session = serde_json::from_str(&json_str).expect("Failed to deserialize session");
608
609 assert_eq!(deserialized.id, session.id);
611 assert_eq!(deserialized.title, session.title);
612 assert_eq!(deserialized.model, session.model);
613 assert_eq!(deserialized.thinking_level, session.thinking_level);
614 assert_eq!(deserialized.system_prompt, session.system_prompt);
615 assert_eq!(deserialized.created_at, session.created_at);
616 assert_eq!(deserialized.updated_at, session.updated_at);
617 assert_eq!(deserialized.total_input_tokens, session.total_input_tokens);
618 assert_eq!(deserialized.total_output_tokens, session.total_output_tokens);
619 assert_eq!(deserialized.session_cost, session.session_cost);
620 assert_eq!(deserialized.api_messages.len(), session.api_messages.len());
621 assert_eq!(deserialized.api_messages[0], session.api_messages[0]);
622 }
623
624 #[test]
625 fn test_session_serialization_preserves_all_fields() {
626 let mut session = Session::new("claude-3-opus", "comprehensive", Some("Custom system prompt"));
627 session.title = "Complex Session".to_string();
628
629 session.api_messages.push(json!({"role": "user", "content": "First message"}));
631 session.api_messages.push(json!({"role": "assistant", "content": "First response"}));
632 session.api_messages.push(json!({"role": "user", "content": "Second message"}));
633
634 session.total_input_tokens = 1500;
636 session.total_output_tokens = 2500;
637 session.session_cost = 0.75;
638
639 let json_str = serde_json::to_string(&session).unwrap();
641 let restored: Session = serde_json::from_str(&json_str).unwrap();
642
643 assert_eq!(restored.id, session.id);
645 assert_eq!(restored.title, "Complex Session");
646 assert_eq!(restored.model, "claude-3-opus");
647 assert_eq!(restored.thinking_level, "comprehensive");
648 assert_eq!(restored.system_prompt.as_ref().unwrap(), "Custom system prompt");
649 assert_eq!(restored.created_at, session.created_at);
650 assert_eq!(restored.updated_at, session.updated_at);
651 assert_eq!(restored.total_input_tokens, 1500);
652 assert_eq!(restored.total_output_tokens, 2500);
653 assert_eq!(restored.session_cost, 0.75);
654 assert_eq!(restored.api_messages.len(), 3);
655 assert_eq!(restored.api_messages[0]["role"], "user");
656 assert_eq!(restored.api_messages[0]["content"], "First message");
657 assert_eq!(restored.api_messages[1]["role"], "assistant");
658 assert_eq!(restored.api_messages[2]["content"], "Second message");
659 }
660
661 #[test]
662 fn test_session_info_from_session_with_messages() {
663 let mut session = Session::new("gpt-3.5-turbo", "normal", None);
664
665 session.api_messages.push(json!({"role": "user", "content": "message 1"}));
667 session.api_messages.push(json!({"role": "assistant", "content": "response 1"}));
668 session.api_messages.push(json!({"role": "user", "content": "message 2"}));
669
670 let info = session.info();
671
672 assert_eq!(info.message_count, 3);
674 assert_eq!(info.id, session.id);
675 assert_eq!(info.model, "gpt-3.5-turbo");
676 }
677
678 #[test]
679 fn test_session_auto_title_truncation() {
680 let mut session = Session::new("gpt-4", "brief", None);
681
682 let long_content = "a".repeat(200);
684 session.api_messages.push(json!({
685 "role": "user",
686 "content": long_content
687 }));
688
689 session.auto_title();
690
691 assert_eq!(session.title.len(), 80);
693 assert_eq!(session.title, "a".repeat(80));
694 }
695
696 #[test]
697 fn test_session_auto_title_skips_non_user_messages() {
698 let mut session = Session::new("gpt-4", "brief", None);
699
700 session.api_messages.push(json!({
702 "role": "assistant",
703 "content": "This should be ignored for auto title"
704 }));
705
706 session.auto_title();
707
708 assert_eq!(session.title, "");
710
711 session.api_messages.push(json!({
713 "role": "system",
714 "content": "System message should also be ignored"
715 }));
716
717 session.auto_title();
718 assert_eq!(session.title, "");
719 }
720
721 #[test]
722 fn test_session_new_generates_unique_ids() {
723 let session1 = Session::new("gpt-4", "brief", None);
724 let session2 = Session::new("gpt-4", "brief", None);
725
726 assert_ne!(session1.id, session2.id);
728 assert!(!session1.id.is_empty());
729 assert!(!session2.id.is_empty());
730 }
731
732 #[test]
733 fn test_session_new_timestamps() {
734 let before = Utc::now();
735 let session = Session::new("gpt-4", "brief", None);
736 let after = Utc::now();
737
738 let created_diff = (session.created_at - before).num_seconds().abs();
740 let updated_diff = (session.updated_at - before).num_seconds().abs();
741
742 assert!(created_diff <= 2, "created_at should be within 2 seconds of now");
743 assert!(updated_diff <= 2, "updated_at should be within 2 seconds of now");
744
745 assert_eq!(session.created_at, session.updated_at);
747
748 assert!(session.created_at <= after);
750 assert!(session.updated_at <= after);
751 }
752
753 #[test]
754 fn test_validate_name() {
755 assert!(validate_name("work").is_ok());
756 assert!(validate_name("my-project-2").is_ok());
757 assert!(validate_name("a").is_ok());
758 assert!(validate_name(&"a".repeat(40)).is_ok());
759
760 assert!(validate_name("").is_err());
761 assert!(validate_name(&"a".repeat(41)).is_err());
762 assert!(validate_name("UPPER").is_err());
763 assert!(validate_name("has space").is_err());
764 assert!(validate_name("under_score").is_err());
765 assert!(validate_name("dots.bad").is_err());
766
767 let err = validate_name("Bad").unwrap_err();
768 assert!(err.contains("Bad"));
769 assert!(err.contains("lowercase") || err.contains("a-z") || err.contains("allowed"));
770 }
771
772 #[test]
773 fn test_clear_name() {
774 let mut s = Session::new("m", "brief", None);
775 s.name = Some("foo".into());
776 s.clear_name();
777 assert_eq!(s.name, None);
778 }
779}