baml_agent/session/
meta.rs1use 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#[derive(Debug, Clone)]
13pub struct SessionMeta {
14 pub path: PathBuf,
16 pub created: u64,
18 pub message_count: usize,
20 pub topic: String,
22 pub size_bytes: u64,
24 pub session_id: Option<String>,
26}
27
28impl SessionMeta {
29 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 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
85pub 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#[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
129pub 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 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 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}