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