agentmux_srv\backend\history/
claude_adapter.rs1use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use super::adapter::*;
13
14pub struct ClaudeHistoryAdapter {
15 base_dirs: Vec<PathBuf>,
17}
18
19impl ClaudeHistoryAdapter {
20 pub fn new() -> Self {
21 let mut base_dirs = Vec::new();
22
23 if let Some(home) = dirs::home_dir() {
24 let personal = home.join(".claude").join("projects");
26 if personal.is_dir() {
27 base_dirs.push(personal);
28 }
29
30 let config_dir = home.join(".config");
32 if config_dir.is_dir() {
33 if let Ok(entries) = fs::read_dir(&config_dir) {
34 for entry in entries.flatten() {
35 let name = entry.file_name();
36 let name_str = name.to_string_lossy();
37 if name_str.starts_with("claude-") {
38 let projects = entry.path().join("projects");
39 if projects.is_dir() {
40 base_dirs.push(projects);
41 }
42 }
43 }
44 }
45 }
46 }
47
48 ClaudeHistoryAdapter { base_dirs }
49 }
50
51 fn count_subagents(session_dir: &Path) -> u32 {
53 let subagents_dir = session_dir.join("subagents");
54 if !subagents_dir.is_dir() {
55 return 0;
56 }
57 fs::read_dir(&subagents_dir)
58 .map(|entries| {
59 entries
60 .flatten()
61 .filter(|e| {
62 let name = e.file_name();
63 let s = name.to_string_lossy();
64 s.starts_with("agent-") && s.ends_with(".jsonl")
65 })
66 .count() as u32
67 })
68 .unwrap_or(0)
69 }
70
71 fn decode_project_path(encoded: &str) -> String {
75 let mut result = encoded.to_string();
77 if result.len() >= 2 && result.as_bytes()[1] == b'-' && result.as_bytes()[0].is_ascii_uppercase() {
79 result = format!("{}:{}", &result[..1], &result[2..]);
80 }
81 result = result.replace('-', "/");
83 result
84 }
85}
86
87impl HistoryAdapter for ClaudeHistoryAdapter {
88 fn provider(&self) -> &str {
89 "claude"
90 }
91
92 fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
93 let mut files = Vec::new();
94
95 for base_dir in &self.base_dirs {
96 let entries = match fs::read_dir(base_dir) {
97 Ok(e) => e,
98 Err(_) => continue,
99 };
100
101 for project_entry in entries.flatten() {
102 let project_path = project_entry.path();
103 if !project_path.is_dir() {
104 if project_path.extension().map_or(false, |e| e == "jsonl") {
106 if let Ok(meta) = project_path.metadata() {
107 let mtime = meta
108 .modified()
109 .ok()
110 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
111 .map(|d| d.as_millis() as i64)
112 .unwrap_or(0);
113 files.push(DiscoveredFile {
114 file_path: project_path.to_string_lossy().into(),
115 mtime_ms: mtime,
116 });
117 }
118 }
119 continue;
120 }
121
122 let dir_entries = match fs::read_dir(&project_path) {
125 Ok(e) => e,
126 Err(_) => continue,
127 };
128 for file_entry in dir_entries.flatten() {
129 let file_path = file_entry.path();
130 if file_path.extension().map_or(false, |e| e == "jsonl") {
131 if file_path
133 .parent()
134 .and_then(|p| p.file_name())
135 .map_or(false, |n| n == "subagents")
136 {
137 continue;
138 }
139 if let Ok(meta) = file_path.metadata() {
140 let mtime = meta
141 .modified()
142 .ok()
143 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
144 .map(|d| d.as_millis() as i64)
145 .unwrap_or(0);
146 files.push(DiscoveredFile {
147 file_path: file_path.to_string_lossy().into(),
148 mtime_ms: mtime,
149 });
150 }
151 }
152 }
153 }
154 }
155
156 files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
157 Ok(files)
158 }
159
160 fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
161 let path = Path::new(file_path);
162 let file = fs::File::open(path)?;
163 let file_size = file.metadata()?.len();
164 let reader = BufReader::new(file);
165
166 let mut first_user_msg = String::new();
167 let mut model = "unknown".to_string();
168 let mut slug = String::new();
169 let mut cwd = String::new();
170 let mut git_branch = String::new();
171 let mut entry_count = 0u32;
172 let mut total_tokens: u64 = 0;
173 let mut first_timestamp: i64 = 0;
174 let mut last_timestamp: i64 = 0;
175 let mut session_id = String::new();
176
177 if let Some(stem) = path.file_stem() {
179 session_id = stem.to_string_lossy().into();
180 }
181
182 let mut lines_iter = reader.lines();
183 let mut found_all_meta = false;
184
185 while let Some(Ok(line)) = lines_iter.next() {
186 if line.trim().is_empty() {
187 continue;
188 }
189
190 let entry: serde_json::Value = match serde_json::from_str(&line) {
191 Ok(v) => v,
192 Err(_) => continue,
193 };
194 entry_count += 1;
195
196 if let Some(ts_str) = entry.get("timestamp").and_then(|v| v.as_str()) {
198 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
199 let ts = dt.timestamp_millis();
200 if first_timestamp == 0 {
201 first_timestamp = ts;
202 }
203 last_timestamp = ts;
204 }
205 }
206
207 if slug.is_empty() {
209 if let Some(s) = entry.get("slug").and_then(|v| v.as_str()) {
210 slug = s.to_string();
211 }
212 }
213
214 if session_id.is_empty() {
216 if let Some(s) = entry.get("sessionId").and_then(|v| v.as_str()) {
217 session_id = s.to_string();
218 }
219 }
220
221 if cwd.is_empty() {
223 if let Some(c) = entry.get("cwd").and_then(|v| v.as_str()) {
224 cwd = c.to_string();
225 }
226 }
227
228 if git_branch.is_empty() {
230 if let Some(b) = entry.get("gitBranch").and_then(|v| v.as_str()) {
231 git_branch = b.to_string();
232 }
233 }
234
235 let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
236
237 if model == "unknown" && entry_type == "assistant" {
239 if let Some(m) = entry.pointer("/message/model").and_then(|v| v.as_str()) {
240 model = m.to_string();
241 }
242 if let Some(usage) = entry.pointer("/message/usage") {
244 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
245 total_tokens += out;
246 }
247 }
248 } else if entry_type == "assistant" {
249 if let Some(usage) = entry.pointer("/message/usage") {
251 if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
252 total_tokens += out;
253 }
254 }
255 }
256
257 if first_user_msg.is_empty() && entry_type == "user" {
259 if let Some(content) = entry.pointer("/message/content") {
260 if let Some(text) = content.as_str() {
261 first_user_msg = text.chars().take(200).collect();
262 } else if let Some(arr) = content.as_array() {
263 for block in arr {
265 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
266 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
267 first_user_msg = text.chars().take(200).collect();
268 break;
269 }
270 }
271 }
272 }
273 }
274 }
275
276 if !first_user_msg.is_empty()
278 && model != "unknown"
279 && !cwd.is_empty()
280 && !slug.is_empty()
281 {
282 found_all_meta = true;
283 break;
284 }
285 }
286
287 if found_all_meta {
289 for remaining_line in lines_iter {
290 if let Ok(line) = remaining_line {
291 if !line.trim().is_empty() {
292 entry_count += 1;
293 }
294 }
295 }
296 }
297
298 if entry_count == 0 {
299 return Ok(None);
300 }
301
302 if cwd.is_empty() {
304 if let Some(parent_name) = path
305 .parent()
306 .and_then(|p| p.file_name())
307 .map(|n| n.to_string_lossy().to_string())
308 {
309 cwd = Self::decode_project_path(&parent_name);
310 }
311 }
312
313 let subagent_count = if let Some(parent) = path.parent() {
315 let session_dir = parent.join(&session_id);
316 Self::count_subagents(&session_dir)
317 } else {
318 0
319 };
320
321 let file_meta = fs::metadata(file_path)?;
322 let modified_at = file_meta
323 .modified()
324 .ok()
325 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
326 .map(|d| d.as_millis() as i64)
327 .unwrap_or(last_timestamp);
328
329 Ok(Some(SessionMeta {
330 session_id,
331 file_path: file_path.to_string(),
332 provider: "claude".to_string(),
333 model,
334 slug,
335 working_directory: cwd,
336 created_at: first_timestamp,
337 modified_at,
338 message_count: entry_count,
339 first_user_message: first_user_msg,
340 file_size_bytes: file_size,
341 git_branch,
342 total_tokens,
343 subagent_count,
344 }))
345 }
346
347 fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
348 let meta = match self.extract_meta(file_path)? {
350 Some(m) => m,
351 None => return Ok(None),
352 };
353
354 let file = fs::File::open(file_path)?;
355 let reader = BufReader::new(file);
356 let mut messages = Vec::new();
357
358 for line in reader.lines() {
359 let line = match line {
360 Ok(l) => l,
361 Err(_) => continue,
362 };
363 if line.trim().is_empty() {
364 continue;
365 }
366
367 let entry: serde_json::Value = match serde_json::from_str(&line) {
368 Ok(v) => v,
369 Err(_) => continue,
370 };
371
372 let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
373
374 let timestamp = entry
376 .get("timestamp")
377 .and_then(|v| v.as_str())
378 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
379 .map(|dt| dt.timestamp_millis())
380 .unwrap_or(0);
381
382 if entry_type == "user" {
383 let content = if let Some(msg) = entry.pointer("/message/content") {
384 if let Some(text) = msg.as_str() {
385 text.to_string()
386 } else if let Some(arr) = msg.as_array() {
387 arr.iter()
388 .filter_map(|block| {
389 if block.get("type").and_then(|v| v.as_str()) == Some("text") {
390 block.get("text").and_then(|v| v.as_str()).map(String::from)
391 } else {
392 None
393 }
394 })
395 .collect::<Vec<_>>()
396 .join("\n")
397 } else {
398 String::new()
399 }
400 } else {
401 String::new()
402 };
403
404 if !content.is_empty() {
405 messages.push(HistoryMessage {
406 role: "user".to_string(),
407 content,
408 timestamp,
409 tool_uses: vec![],
410 });
411 }
412 } else if entry_type == "assistant" {
413 let mut text_parts = Vec::new();
414 let mut tool_uses = Vec::new();
415
416 if let Some(content_arr) = entry.pointer("/message/content").and_then(|v| v.as_array()) {
417 for block in content_arr {
418 let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
419 match block_type {
420 "text" => {
421 if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
422 text_parts.push(text.to_string());
423 }
424 }
425 "tool_use" => {
426 let name = block
427 .get("name")
428 .and_then(|v| v.as_str())
429 .unwrap_or("unknown")
430 .to_string();
431 let arg_summary = if let Some(input) = block.get("input") {
433 if let Some(obj) = input.as_object() {
434 obj.iter()
436 .next()
437 .map(|(k, v)| {
438 let val_str = if let Some(s) = v.as_str() {
439 s.chars().take(100).collect::<String>()
440 } else {
441 v.to_string().chars().take(100).collect::<String>()
442 };
443 format!("{}: {}", k, val_str)
444 })
445 .unwrap_or_default()
446 } else {
447 String::new()
448 }
449 } else {
450 String::new()
451 };
452 tool_uses.push(ToolUseSummary {
453 name,
454 argument_summary: arg_summary,
455 });
456 }
457 _ => {}
459 }
460 }
461 }
462
463 let content = text_parts.join("\n");
464 if !content.is_empty() || !tool_uses.is_empty() {
465 messages.push(HistoryMessage {
466 role: "assistant".to_string(),
467 content,
468 timestamp,
469 tool_uses,
470 });
471 }
472 }
473 }
474
475 Ok(Some(HistorySession { meta, messages }))
476 }
477}