Skip to main content

crabtalk_core/runtime/
session.rs

1//! Session — conversation container with append-only JSONL persistence.
2//!
3//! Files are organized as `sessions/{YYYY-MM-DD}/{agent}_{sender}_{seq}.jsonl`.
4//! After `set_title`, renamed to `{agent}_{sender}_{seq}_{title_slug}.jsonl`.
5//!
6//! Append-only. Compact markers (`{"compact":"..."}`) separate archived
7//! history from the working context. Loading reads from the last compact
8//! marker forward.
9
10use 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/// Session metadata — first line of a JSONL session file.
20#[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/// A JSONL line that is either a message or a compact marker.
32#[derive(Serialize, Deserialize)]
33#[serde(untagged)]
34enum SessionLine {
35    Compact { compact: String },
36    Message(Message),
37}
38
39/// A conversation session tied to a specific agent.
40#[derive(Debug, Clone)]
41pub struct Session {
42    /// Unique session identifier (monotonic counter, runtime-only).
43    pub id: u64,
44    /// Name of the agent this session is bound to.
45    pub agent: String,
46    /// Conversation history (the working context for the LLM).
47    pub history: Vec<Message>,
48    /// Origin of this session (e.g. "user", "tg:12345").
49    pub created_by: String,
50    /// Conversation title (set by the `set_title` tool).
51    pub title: String,
52    /// Accumulated active time in seconds (persisted to meta).
53    pub uptime_secs: u64,
54    /// When this session was loaded/created in this process.
55    pub created_at: Instant,
56    /// Path to the JSONL persistence file.
57    pub file_path: Option<PathBuf>,
58}
59
60impl Session {
61    /// Create a new session with an empty history.
62    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    /// Initialize a new JSONL file in the flat sessions directory.
76    ///
77    /// Creates `{sessions_dir}/{agent}_{sender}_{seq}.jsonl` with
78    /// seq auto-incremented globally per identity.
79    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    /// Set the conversation title and rename the file to include the title slug.
113    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        // Rewrite meta line with the title.
121        self.rewrite_meta();
122
123        // Rename file: insert title slug before `.jsonl`.
124        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    /// Rewrite the meta line (first line) of the JSONL file.
137    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        // Replace only the first line.
155        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    /// Append messages to the JSONL file. Skips auto-injected messages.
161    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    /// Append a compact marker to the JSONL file.
183    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    /// Load the working context from a JSONL session file.
203    ///
204    /// Reads from the last `{"compact":"..."}` marker forward. If no compact
205    /// marker exists, loads all messages.
206    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
252/// Find the latest session file for an (agent, created_by) identity.
253///
254/// Scans the flat sessions directory for files matching the identity prefix
255/// and returns the one with the highest seq number.
256pub 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
282/// Compute the next seq number for a given prefix in a directory.
283fn 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
304/// Sanitize a string into a filesystem-safe slug.
305pub 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}