Skip to main content

claude_code/
sessions.rs

1//! Session history helpers aligned with the Python SDK.
2//!
3//! This module scans Claude session transcript files under
4//! `~/.claude/projects/` (or `CLAUDE_CONFIG_DIR`) and exposes:
5//!
6//! - [`list_sessions`] for lightweight session metadata
7//! - [`get_session_messages`] for reconstructed conversation messages
8
9use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::time::UNIX_EPOCH;
14
15use serde_json::Value;
16
17use crate::types::{SDKSessionInfo, SessionMessage};
18
19const MAX_SANITIZED_LENGTH: usize = 200;
20
21#[derive(Debug, Clone)]
22struct TranscriptEntry {
23    entry_type: String,
24    uuid: String,
25    parent_uuid: Option<String>,
26    session_id: Option<String>,
27    message: Option<Value>,
28    is_sidechain: bool,
29    is_meta: bool,
30    team_name: Option<String>,
31}
32
33fn validate_uuid(maybe_uuid: &str) -> bool {
34    if maybe_uuid.len() != 36 {
35        return false;
36    }
37    for (i, ch) in maybe_uuid.chars().enumerate() {
38        let is_dash = matches!(i, 8 | 13 | 18 | 23);
39        if is_dash {
40            if ch != '-' {
41                return false;
42            }
43        } else if !ch.is_ascii_hexdigit() {
44            return false;
45        }
46    }
47    true
48}
49
50fn simple_hash(input: &str) -> String {
51    let mut hash: i32 = 0;
52    for ch in input.chars() {
53        hash = hash
54            .wrapping_shl(5)
55            .wrapping_sub(hash)
56            .wrapping_add(ch as i32);
57    }
58    let mut value = hash.unsigned_abs() as u64;
59    if value == 0 {
60        return "0".to_string();
61    }
62    let digits = b"0123456789abcdefghijklmnopqrstuvwxyz";
63    let mut out = Vec::new();
64    while value > 0 {
65        out.push(digits[(value % 36) as usize] as char);
66        value /= 36;
67    }
68    out.iter().rev().collect()
69}
70
71fn sanitize_path(name: &str) -> String {
72    let mut out = String::with_capacity(name.len());
73    for ch in name.chars() {
74        if ch.is_ascii_alphanumeric() {
75            out.push(ch);
76        } else {
77            out.push('-');
78        }
79    }
80    if out.len() <= MAX_SANITIZED_LENGTH {
81        return out;
82    }
83    format!("{}-{}", &out[..MAX_SANITIZED_LENGTH], simple_hash(name))
84}
85
86fn claude_config_home_dir() -> PathBuf {
87    if let Ok(path) = std::env::var("CLAUDE_CONFIG_DIR") {
88        return PathBuf::from(path);
89    }
90    if let Ok(home) = std::env::var("HOME") {
91        return PathBuf::from(home).join(".claude");
92    }
93    if let Ok(home) = std::env::var("USERPROFILE") {
94        return PathBuf::from(home).join(".claude");
95    }
96    PathBuf::from(".claude")
97}
98
99fn projects_dir() -> PathBuf {
100    claude_config_home_dir().join("projects")
101}
102
103fn canonicalize_dir(directory: &str) -> String {
104    fs::canonicalize(directory)
105        .unwrap_or_else(|_| PathBuf::from(directory))
106        .to_string_lossy()
107        .to_string()
108}
109
110fn find_project_dir(project_path: &str) -> Option<PathBuf> {
111    let sanitized = sanitize_path(project_path);
112    let exact = projects_dir().join(&sanitized);
113    if exact.is_dir() {
114        return Some(exact);
115    }
116
117    if sanitized.len() <= MAX_SANITIZED_LENGTH {
118        return None;
119    }
120
121    let prefix = &sanitized[..MAX_SANITIZED_LENGTH];
122    let entries = fs::read_dir(projects_dir()).ok()?;
123    for entry in entries.flatten() {
124        if !entry.path().is_dir() {
125            continue;
126        }
127        if let Some(name) = entry.file_name().to_str()
128            && name.starts_with(&(prefix.to_string() + "-"))
129        {
130            return Some(entry.path());
131        }
132    }
133    None
134}
135
136fn parse_jsonl(content: &str) -> Vec<Value> {
137    content
138        .lines()
139        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
140        .collect()
141}
142
143fn extract_command_name(text: &str) -> Option<String> {
144    let start_tag = "<command-name>";
145    let end_tag = "</command-name>";
146    let start = text.find(start_tag)?;
147    let after_start = start + start_tag.len();
148    let end = text[after_start..].find(end_tag)?;
149    Some(text[after_start..after_start + end].trim().to_string())
150}
151
152fn is_skipped_first_prompt(text: &str) -> bool {
153    let trimmed = text.trim();
154    trimmed.is_empty()
155        || trimmed.starts_with("<local-command-stdout>")
156        || trimmed.starts_with("<session-start-hook>")
157        || trimmed.starts_with("<tick>")
158        || trimmed.starts_with("<goal>")
159        || trimmed.starts_with("[Request interrupted by user")
160        || (trimmed.starts_with("<ide_opened_file>") && trimmed.ends_with("</ide_opened_file>"))
161        || (trimmed.starts_with("<ide_selection>") && trimmed.ends_with("</ide_selection>"))
162}
163
164fn extract_first_prompt(entries: &[Value]) -> Option<String> {
165    let mut command_fallback: Option<String> = None;
166
167    for entry in entries {
168        let Some(obj) = entry.as_object() else {
169            continue;
170        };
171        if obj.get("type").and_then(Value::as_str) != Some("user") {
172            continue;
173        }
174        if obj.get("isMeta").and_then(Value::as_bool).unwrap_or(false) {
175            continue;
176        }
177        if obj
178            .get("isCompactSummary")
179            .and_then(Value::as_bool)
180            .unwrap_or(false)
181        {
182            continue;
183        }
184
185        let Some(message) = obj.get("message").and_then(Value::as_object) else {
186            continue;
187        };
188        let Some(content) = message.get("content") else {
189            continue;
190        };
191
192        let texts: Vec<String> = if let Some(text) = content.as_str() {
193            vec![text.to_string()]
194        } else if let Some(blocks) = content.as_array() {
195            blocks
196                .iter()
197                .filter_map(|block| {
198                    let block_obj = block.as_object()?;
199                    if block_obj.get("type").and_then(Value::as_str) == Some("text") {
200                        block_obj
201                            .get("text")
202                            .and_then(Value::as_str)
203                            .map(ToString::to_string)
204                    } else {
205                        None
206                    }
207                })
208                .collect()
209        } else {
210            Vec::new()
211        };
212
213        for raw in texts {
214            let candidate = raw.replace('\n', " ").trim().to_string();
215            if candidate.is_empty() {
216                continue;
217            }
218
219            if let Some(name) = extract_command_name(&candidate) {
220                if command_fallback.is_none() && !name.is_empty() {
221                    command_fallback = Some(name);
222                }
223                continue;
224            }
225
226            if is_skipped_first_prompt(&candidate) {
227                continue;
228            }
229
230            if candidate.chars().count() > 200 {
231                let truncated: String = candidate.chars().take(200).collect();
232                return Some(format!("{}...", truncated.trim_end()));
233            }
234            return Some(candidate);
235        }
236    }
237
238    command_fallback
239}
240
241fn extract_last_string_field(entries: &[Value], key: &str) -> Option<String> {
242    entries.iter().rev().find_map(|entry| {
243        entry
244            .as_object()?
245            .get(key)?
246            .as_str()
247            .map(ToString::to_string)
248    })
249}
250
251fn extract_first_string_field(entries: &[Value], key: &str) -> Option<String> {
252    entries.iter().find_map(|entry| {
253        entry
254            .as_object()?
255            .get(key)?
256            .as_str()
257            .map(ToString::to_string)
258    })
259}
260
261fn millis_since_epoch(modified: std::time::SystemTime) -> i64 {
262    modified
263        .duration_since(UNIX_EPOCH)
264        .map(|d| d.as_millis() as i64)
265        .unwrap_or(0)
266}
267
268fn read_sessions_from_dir(project_dir: &Path, project_path: Option<&str>) -> Vec<SDKSessionInfo> {
269    let mut results = Vec::new();
270    let entries = match fs::read_dir(project_dir) {
271        Ok(entries) => entries,
272        Err(_) => return results,
273    };
274
275    for entry in entries.flatten() {
276        let path = entry.path();
277        if !path.is_file() {
278            continue;
279        }
280        if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
281            continue;
282        }
283
284        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
285            continue;
286        };
287        if !validate_uuid(stem) {
288            continue;
289        }
290
291        let metadata = match fs::metadata(&path) {
292            Ok(metadata) => metadata,
293            Err(_) => continue,
294        };
295        let content = match fs::read_to_string(&path) {
296            Ok(content) => content,
297            Err(_) => continue,
298        };
299        if content.trim().is_empty() {
300            continue;
301        }
302
303        let first_line = content.lines().next().unwrap_or_default();
304        if first_line.contains("\"isSidechain\":true")
305            || first_line.contains("\"isSidechain\": true")
306        {
307            continue;
308        }
309
310        let entries = parse_jsonl(&content);
311        let custom_title = extract_last_string_field(&entries, "customTitle");
312        let first_prompt = extract_first_prompt(&entries);
313        let summary = custom_title
314            .clone()
315            .or_else(|| extract_last_string_field(&entries, "summary"))
316            .or_else(|| first_prompt.clone());
317        let Some(summary) = summary else {
318            continue;
319        };
320
321        let git_branch = extract_last_string_field(&entries, "gitBranch")
322            .or_else(|| extract_first_string_field(&entries, "gitBranch"));
323        let cwd = extract_first_string_field(&entries, "cwd")
324            .or_else(|| project_path.map(ToString::to_string));
325
326        results.push(SDKSessionInfo {
327            session_id: stem.to_string(),
328            summary,
329            last_modified: metadata
330                .modified()
331                .map(millis_since_epoch)
332                .unwrap_or_default(),
333            file_size: metadata.len(),
334            custom_title,
335            first_prompt,
336            git_branch,
337            cwd,
338        });
339    }
340
341    results
342}
343
344fn get_worktree_paths(cwd: &str) -> Vec<String> {
345    let output = match Command::new("git")
346        .args(["worktree", "list", "--porcelain"])
347        .current_dir(cwd)
348        .output()
349    {
350        Ok(output) => output,
351        Err(_) => return Vec::new(),
352    };
353
354    if !output.status.success() {
355        return Vec::new();
356    }
357
358    String::from_utf8_lossy(&output.stdout)
359        .lines()
360        .filter_map(|line| line.strip_prefix("worktree ").map(ToString::to_string))
361        .collect()
362}
363
364fn deduplicate_and_sort(
365    mut sessions: Vec<SDKSessionInfo>,
366    limit: Option<usize>,
367) -> Vec<SDKSessionInfo> {
368    let mut by_id: HashMap<String, SDKSessionInfo> = HashMap::new();
369    for session in sessions.drain(..) {
370        let replace = by_id
371            .get(&session.session_id)
372            .map(|existing| session.last_modified > existing.last_modified)
373            .unwrap_or(true);
374        if replace {
375            by_id.insert(session.session_id.clone(), session);
376        }
377    }
378
379    let mut values: Vec<SDKSessionInfo> = by_id.into_values().collect();
380    values.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
381    if let Some(limit) = limit
382        && limit > 0
383    {
384        values.truncate(limit);
385    }
386    values
387}
388
389fn list_sessions_for_project(
390    directory: &str,
391    limit: Option<usize>,
392    include_worktrees: bool,
393) -> Vec<SDKSessionInfo> {
394    let canonical = canonicalize_dir(directory);
395    let mut candidates = vec![canonical.clone()];
396    if include_worktrees {
397        for path in get_worktree_paths(&canonical) {
398            if !candidates.iter().any(|candidate| candidate == &path) {
399                candidates.push(path);
400            }
401        }
402    }
403
404    let mut all = Vec::new();
405    let mut seen = HashSet::new();
406    for candidate in candidates {
407        let Some(project_dir) = find_project_dir(&candidate) else {
408            continue;
409        };
410        let key = project_dir.to_string_lossy().to_string();
411        if seen.contains(&key) {
412            continue;
413        }
414        seen.insert(key);
415        all.extend(read_sessions_from_dir(&project_dir, Some(&candidate)));
416    }
417    deduplicate_and_sort(all, limit)
418}
419
420/// Lists session metadata from Claude session transcript files.
421pub fn list_sessions(
422    directory: Option<&str>,
423    limit: Option<usize>,
424    include_worktrees: bool,
425) -> Vec<SDKSessionInfo> {
426    if let Some(directory) = directory {
427        return list_sessions_for_project(directory, limit, include_worktrees);
428    }
429
430    let mut all = Vec::new();
431    let entries = match fs::read_dir(projects_dir()) {
432        Ok(entries) => entries,
433        Err(_) => return all,
434    };
435    for entry in entries.flatten() {
436        let path = entry.path();
437        if path.is_dir() {
438            all.extend(read_sessions_from_dir(&path, None));
439        }
440    }
441
442    deduplicate_and_sort(all, limit)
443}
444
445fn parse_transcript_entries(content: &str) -> Vec<TranscriptEntry> {
446    content
447        .lines()
448        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
449        .filter_map(|entry| {
450            let obj = entry.as_object()?;
451            let entry_type = obj.get("type")?.as_str()?.to_string();
452            if !matches!(
453                entry_type.as_str(),
454                "user" | "assistant" | "progress" | "system" | "attachment"
455            ) {
456                return None;
457            }
458            let uuid = obj.get("uuid")?.as_str()?.to_string();
459            Some(TranscriptEntry {
460                entry_type,
461                uuid,
462                parent_uuid: obj
463                    .get("parentUuid")
464                    .and_then(Value::as_str)
465                    .map(ToString::to_string),
466                session_id: obj
467                    .get("sessionId")
468                    .and_then(Value::as_str)
469                    .map(ToString::to_string),
470                message: obj.get("message").cloned(),
471                is_sidechain: obj
472                    .get("isSidechain")
473                    .and_then(Value::as_bool)
474                    .unwrap_or(false),
475                is_meta: obj.get("isMeta").and_then(Value::as_bool).unwrap_or(false),
476                team_name: obj
477                    .get("teamName")
478                    .and_then(Value::as_str)
479                    .map(ToString::to_string),
480            })
481        })
482        .collect()
483}
484
485fn build_conversation_chain(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> {
486    if entries.is_empty() {
487        return Vec::new();
488    }
489
490    let mut by_uuid = HashMap::new();
491    let mut entry_index = HashMap::new();
492    for (index, entry) in entries.iter().enumerate() {
493        by_uuid.insert(entry.uuid.clone(), entry.clone());
494        entry_index.insert(entry.uuid.clone(), index);
495    }
496
497    let parent_uuids: HashSet<String> = entries
498        .iter()
499        .filter_map(|entry| entry.parent_uuid.clone())
500        .collect();
501    let terminals: Vec<TranscriptEntry> = entries
502        .iter()
503        .filter(|entry| !parent_uuids.contains(&entry.uuid))
504        .cloned()
505        .collect();
506
507    let mut leaves = Vec::new();
508    for terminal in terminals {
509        let mut current = Some(terminal);
510        let mut seen = HashSet::new();
511        while let Some(entry) = current {
512            if !seen.insert(entry.uuid.clone()) {
513                break;
514            }
515            if matches!(entry.entry_type.as_str(), "user" | "assistant") {
516                leaves.push(entry);
517                break;
518            }
519            current = entry
520                .parent_uuid
521                .as_ref()
522                .and_then(|uuid| by_uuid.get(uuid))
523                .cloned();
524        }
525    }
526
527    if leaves.is_empty() {
528        return Vec::new();
529    }
530
531    let main_leaves: Vec<TranscriptEntry> = leaves
532        .iter()
533        .filter(|leaf| !leaf.is_sidechain && !leaf.is_meta && leaf.team_name.is_none())
534        .cloned()
535        .collect();
536
537    let source = if main_leaves.is_empty() {
538        &leaves
539    } else {
540        &main_leaves
541    };
542    let leaf = source
543        .iter()
544        .max_by_key(|entry| entry_index.get(&entry.uuid).copied().unwrap_or(0))
545        .cloned();
546
547    let Some(mut current) = leaf else {
548        return Vec::new();
549    };
550
551    let mut chain = Vec::new();
552    let mut seen = HashSet::new();
553    loop {
554        if !seen.insert(current.uuid.clone()) {
555            break;
556        }
557        chain.push(current.clone());
558        let Some(parent_uuid) = current.parent_uuid.clone() else {
559            break;
560        };
561        let Some(parent) = by_uuid.get(&parent_uuid).cloned() else {
562            break;
563        };
564        current = parent;
565    }
566
567    chain.reverse();
568    chain
569}
570
571fn is_visible_message(entry: &TranscriptEntry) -> bool {
572    matches!(entry.entry_type.as_str(), "user" | "assistant")
573        && !entry.is_meta
574        && !entry.is_sidechain
575        && entry.team_name.is_none()
576}
577
578fn read_session_file(session_id: &str, directory: Option<&str>) -> Option<String> {
579    let file_name = format!("{session_id}.jsonl");
580
581    if let Some(directory) = directory {
582        let canonical = canonicalize_dir(directory);
583        let mut candidates = vec![canonical.clone()];
584        for path in get_worktree_paths(&canonical) {
585            if !candidates.iter().any(|candidate| candidate == &path) {
586                candidates.push(path);
587            }
588        }
589
590        for candidate in candidates {
591            let Some(project_dir) = find_project_dir(&candidate) else {
592                continue;
593            };
594            let path = project_dir.join(&file_name);
595            if let Ok(content) = fs::read_to_string(path) {
596                return Some(content);
597            }
598        }
599        return None;
600    }
601
602    let projects = fs::read_dir(projects_dir()).ok()?;
603    for project in projects.flatten() {
604        let path = project.path().join(&file_name);
605        if let Ok(content) = fs::read_to_string(path) {
606            return Some(content);
607        }
608    }
609    None
610}
611
612/// Returns chronological user/assistant messages for a saved Claude session.
613pub fn get_session_messages(
614    session_id: &str,
615    directory: Option<&str>,
616    limit: Option<usize>,
617    offset: usize,
618) -> Vec<SessionMessage> {
619    if !validate_uuid(session_id) {
620        return Vec::new();
621    }
622
623    let Some(content) = read_session_file(session_id, directory) else {
624        return Vec::new();
625    };
626    let entries = parse_transcript_entries(&content);
627    let chain = build_conversation_chain(&entries);
628    let mut messages: Vec<SessionMessage> = chain
629        .into_iter()
630        .filter(is_visible_message)
631        .map(|entry| SessionMessage {
632            type_: entry.entry_type,
633            uuid: entry.uuid,
634            session_id: entry.session_id.unwrap_or_default(),
635            message: entry.message.unwrap_or(Value::Null),
636            parent_tool_use_id: None,
637        })
638        .collect();
639
640    if offset > 0 {
641        if offset >= messages.len() {
642            return Vec::new();
643        }
644        messages = messages.split_off(offset);
645    }
646
647    if let Some(limit) = limit
648        && limit > 0
649        && messages.len() > limit
650    {
651        messages.truncate(limit);
652    }
653
654    messages
655}