Skip to main content

crabtalk_core/runtime/
session.rs

1//! Session — lightweight history container for agent conversations.
2
3use crate::model::Message;
4use serde::{Deserialize, Serialize};
5use std::{
6    fs::{self, OpenOptions},
7    io::Write,
8    path::{Path, PathBuf},
9    time::{Instant, SystemTime},
10};
11
12/// Session metadata written as the first line of a JSONL session file.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMeta {
15    pub agent: String,
16    pub created_by: String,
17    pub created_at: String,
18}
19
20/// A conversation session tied to a specific agent.
21///
22/// Sessions own the conversation history and are stored behind
23/// `Arc<Mutex<Session>>` in the runtime. Multiple sessions can
24/// reference the same agent — each with independent history.
25#[derive(Debug, Clone)]
26pub struct Session {
27    /// Unique session identifier (monotonic counter).
28    pub id: u64,
29    /// Name of the agent this session is bound to.
30    pub agent: String,
31    /// Conversation history (user/assistant/tool messages).
32    pub history: Vec<Message>,
33    /// Origin of this session (e.g. "user", "telegram:12345", agent name).
34    pub created_by: String,
35    /// When this session was created.
36    pub created_at: Instant,
37    /// Path to the JSONL persistence file (set when persistence is enabled).
38    pub file_path: Option<PathBuf>,
39}
40
41impl Session {
42    /// Create a new session with an empty history.
43    pub fn new(id: u64, agent: impl Into<String>, created_by: impl Into<String>) -> Self {
44        Self {
45            id,
46            agent: agent.into(),
47            history: Vec::new(),
48            created_by: created_by.into(),
49            created_at: Instant::now(),
50            file_path: None,
51        }
52    }
53
54    /// Initialize a JSONL persistence file in the given directory.
55    ///
56    /// Writes the metadata header line and sets `self.file_path`.
57    pub fn init_file(&mut self, sessions_dir: &Path) {
58        let _ = fs::create_dir_all(sessions_dir);
59        let ts = SystemTime::now()
60            .duration_since(SystemTime::UNIX_EPOCH)
61            .unwrap_or_default()
62            .as_secs();
63        let filename = format!("{}_{ts}_{}.jsonl", self.agent, self.id);
64        let path = sessions_dir.join(filename);
65
66        let meta = SessionMeta {
67            agent: self.agent.clone(),
68            created_by: self.created_by.clone(),
69            created_at: chrono::Utc::now().to_rfc3339(),
70        };
71
72        match OpenOptions::new()
73            .create(true)
74            .write(true)
75            .truncate(true)
76            .open(&path)
77        {
78            Ok(mut f) => {
79                if let Ok(json) = serde_json::to_string(&meta) {
80                    let _ = writeln!(f, "{json}");
81                }
82                self.file_path = Some(path);
83            }
84            Err(e) => tracing::warn!("failed to create session file: {e}"),
85        }
86    }
87
88    /// Persist the full session history to the JSONL file.
89    ///
90    /// Overwrites the file with the current metadata + all non-auto-injected
91    /// messages. No-op if `file_path` is not set.
92    pub fn persist(&self) {
93        let Some(ref path) = self.file_path else {
94            return;
95        };
96
97        let meta = SessionMeta {
98            agent: self.agent.clone(),
99            created_by: self.created_by.clone(),
100            created_at: chrono::Utc::now().to_rfc3339(),
101        };
102
103        let mut file = match OpenOptions::new()
104            .create(true)
105            .write(true)
106            .truncate(true)
107            .open(path)
108        {
109            Ok(f) => f,
110            Err(e) => {
111                tracing::warn!("failed to persist session {}: {e}", self.id);
112                return;
113            }
114        };
115
116        if let Ok(json) = serde_json::to_string(&meta) {
117            let _ = writeln!(file, "{json}");
118        }
119
120        for msg in &self.history {
121            if msg.auto_injected {
122                continue;
123            }
124            if let Ok(json) = serde_json::to_string(msg) {
125                let _ = writeln!(file, "{json}");
126            }
127        }
128    }
129}