Skip to main content

baml_agent/session/
meta.rs

1//! Session metadata, listing, search, and import.
2
3use std::fs::{self, OpenOptions};
4use std::io::{BufRead, BufReader, Write};
5use std::path::{Path, PathBuf};
6
7use super::format::{make_persisted, parse_entry};
8use super::time::{truncate_topic, uuid_v7_timestamp};
9use super::traits::EntryType;
10
11/// Metadata about a saved session (lightweight, no full message load).
12#[derive(Debug, Clone)]
13pub struct SessionMeta {
14    /// Path to the JSONL file.
15    pub path: PathBuf,
16    /// Unix timestamp (seconds) — extracted from UUID v7, filename, or file mtime.
17    pub created: u64,
18    /// Number of messages (lines in JSONL).
19    pub message_count: usize,
20    /// First user message — serves as session "topic".
21    pub topic: String,
22    /// File size in bytes.
23    pub size_bytes: u64,
24    /// Session ID (UUID v7).
25    pub session_id: Option<String>,
26}
27
28impl SessionMeta {
29    /// Extract metadata from a session JSONL file without loading all messages.
30    pub(crate) fn from_path(path: &Path) -> Option<Self> {
31        let meta = fs::metadata(path).ok()?;
32        let filename = path.file_stem()?.to_str()?;
33
34        let file = fs::File::open(path).ok()?;
35        let reader = BufReader::new(file);
36        let mut message_count = 0;
37        let mut topic = String::new();
38        let mut session_id = None;
39        let mut first_uuid = None;
40
41        for line in reader.lines().map_while(Result::ok) {
42            let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
43                continue;
44            };
45            message_count += 1;
46
47            if session_id.is_none() {
48                session_id = value["sessionId"].as_str().map(String::from);
49            }
50            if first_uuid.is_none() {
51                first_uuid = value["uuid"].as_str().map(String::from);
52            }
53
54            if topic.is_empty() {
55                if let Some((EntryType::User, content)) = parse_entry(&value) {
56                    topic = truncate_topic(&content);
57                }
58            }
59        }
60
61        // Determine creation time: UUID v7 timestamp > filename > file mtime
62        let created = first_uuid
63            .as_deref()
64            .and_then(uuid_v7_timestamp)
65            .or_else(|| filename.strip_prefix("session_")?.parse::<u64>().ok())
66            .or_else(|| {
67                meta.modified()
68                    .ok()
69                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
70                    .map(|d| d.as_secs())
71            })
72            .unwrap_or(0);
73
74        Some(Self {
75            path: path.to_path_buf(),
76            created,
77            message_count,
78            topic,
79            size_bytes: meta.len(),
80            session_id,
81        })
82    }
83}
84
85/// List all sessions in a directory, sorted by creation time (newest first).
86pub fn list_sessions(session_dir: &str) -> Vec<SessionMeta> {
87    let Ok(entries) = fs::read_dir(session_dir) else {
88        return vec![];
89    };
90    let mut sessions: Vec<SessionMeta> = entries
91        .filter_map(|e| e.ok())
92        .filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
93        .filter_map(|e| SessionMeta::from_path(&e.path()))
94        .collect();
95    sessions.sort_by(|a, b| b.created.cmp(&a.created));
96    sessions
97}
98
99/// Search sessions by fuzzy-matching their topic (first user message).
100///
101/// Returns matches sorted by score (best first). Requires the `search` feature.
102#[cfg(feature = "search")]
103pub fn search_sessions(session_dir: &str, query: &str) -> Vec<(u32, SessionMeta)> {
104    use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
105    use nucleo_matcher::{Config, Matcher, Utf32Str};
106
107    let sessions = list_sessions(session_dir);
108    if sessions.is_empty() || query.is_empty() {
109        return sessions.into_iter().map(|s| (0, s)).collect();
110    }
111
112    let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
113    let mut matcher = Matcher::new(Config::DEFAULT);
114
115    let mut matches: Vec<(u32, SessionMeta)> = sessions
116        .into_iter()
117        .filter_map(|s| {
118            let haystack = Utf32Str::Ascii(s.topic.as_bytes());
119            pattern
120                .score(haystack, &mut matcher)
121                .map(|score| (score, s))
122        })
123        .collect();
124
125    matches.sort_by(|a, b| b.0.cmp(&a.0));
126    matches
127}
128
129/// Import a Claude Code session JSONL into our session directory.
130///
131/// Since we now use the same format, this mostly copies entries through,
132/// filtering to user/assistant/system messages. Legacy Claude sessions
133/// with different structure are also handled.
134///
135/// Returns the output path of the imported session.
136pub fn import_claude_session(claude_path: &Path, output_dir: &str) -> Option<PathBuf> {
137    let file = fs::File::open(claude_path).ok()?;
138    let reader = BufReader::new(file);
139
140    let session_id = uuid::Uuid::now_v7().to_string();
141    let mut entries: Vec<String> = Vec::new();
142    let mut last_uuid = None;
143
144    for line in reader.lines().map_while(Result::ok) {
145        let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
146            continue;
147        };
148
149        let type_str = value["type"].as_str().unwrap_or("");
150        if EntryType::parse(type_str).is_none() {
151            continue;
152        }
153
154        // If it already has the full format, pass through with our session_id
155        if value.get("message").is_some() && value.get("uuid").is_some() {
156            let mut entry = value.clone();
157            entry["sessionId"] = serde_json::Value::String(session_id.clone());
158            if let Some(uid) = entry["uuid"].as_str() {
159                last_uuid = Some(uid.to_string());
160            }
161            if let Ok(json) = serde_json::to_string(&entry) {
162                entries.push(json);
163            }
164            continue;
165        }
166
167        // Extract content for non-standard entries
168        if let Some((entry_type, content)) = parse_entry(&value) {
169            let persisted = make_persisted(entry_type, &content, &session_id, last_uuid.as_deref());
170            last_uuid = Some(persisted.uuid.clone());
171            if let Ok(json) = serde_json::to_string(&persisted) {
172                entries.push(json);
173            }
174        }
175    }
176
177    if entries.is_empty() {
178        return None;
179    }
180
181    fs::create_dir_all(output_dir).ok()?;
182    let output_path = Path::new(output_dir).join(format!("{}.jsonl", session_id));
183
184    let mut file = OpenOptions::new()
185        .create(true)
186        .truncate(true)
187        .write(true)
188        .open(&output_path)
189        .ok()?;
190    for json in &entries {
191        let _ = writeln!(file, "{}", json);
192    }
193
194    Some(output_path)
195}