Skip to main content

open_loops/sessions/
claude_code.rs

1//! Adapter for Claude Code sessions (~/.claude/projects/<path-encoded>/*.jsonl).
2//! WARNING: internal Claude Code format, not a public API — may change.
3//! Parsing is therefore tolerant: a bad line is skipped, never aborts (spec risk 1).
4use super::{SessionExcerpt, SessionSource};
5use anyhow::Result;
6use chrono::{DateTime, Duration, Utc};
7use std::path::{Path, PathBuf};
8
9pub struct ClaudeCode {
10    pub projects_dir: PathBuf,
11}
12
13/// Claude Code encodes the project path by replacing '/' and '.' with '-'.
14/// e.g. /home/g/repo/x -> -home-g-repo-x
15pub fn encode_project_path(p: &Path) -> String {
16    p.to_string_lossy().replace(['/', '.'], "-")
17}
18
19/// Extracts text from a session jsonl line. None for non-message,
20/// corrupted, or empty lines (tolerant parsing).
21pub fn extract_text(line: &str) -> Option<String> {
22    let v: serde_json::Value = serde_json::from_str(line).ok()?;
23    let role = v.get("type")?.as_str()?;
24    if role != "user" && role != "assistant" {
25        return None;
26    }
27    let content = v.get("message")?.get("content")?;
28    let text = match content {
29        serde_json::Value::String(s) => s.clone(),
30        serde_json::Value::Array(parts) => parts
31            .iter()
32            .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
33            .collect::<Vec<_>>()
34            .join("\n"),
35        _ => return None,
36    };
37    let text = text.trim();
38    if text.is_empty() {
39        None
40    } else {
41        Some(format!("[{role}] {text}"))
42    }
43}
44
45/// Reads the last `max_bytes` of the file and extracts message text.
46/// The end of the conversation concentrates the "where I left off" signal (spec decision).
47fn read_tail_text(path: &Path, max_bytes: u64) -> Result<String> {
48    let raw = std::fs::read(path)?;
49    let start = raw.len().saturating_sub(max_bytes as usize);
50    let tail = String::from_utf8_lossy(&raw[start..]);
51    let mut lines = tail.lines();
52    if start > 0 {
53        lines.next(); // first line may be cut mid-way
54    }
55    Ok(lines
56        .filter_map(extract_text)
57        .collect::<Vec<_>>()
58        .join("\n"))
59}
60
61impl SessionSource for ClaudeCode {
62    /// Excerpts of the sessions most relevant to the branch.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the project directory cannot be read.
67    fn excerpts(
68        &self,
69        repo_path: &Path,
70        branch: &str,
71        window: (DateTime<Utc>, DateTime<Utc>),
72        max_sessions: usize,
73        max_kb: u64,
74    ) -> Result<Vec<SessionExcerpt>> {
75        let dir = self.projects_dir.join(encode_project_path(repo_path));
76        if !dir.is_dir() {
77            return Ok(vec![]);
78        }
79        let pad = Duration::days(7);
80        let (start, end) = (window.0 - pad, window.1 + pad);
81        let mut candidates: Vec<(DateTime<Utc>, PathBuf, bool, bool)> = Vec::new();
82        for entry in std::fs::read_dir(&dir)?.flatten() {
83            let path = entry.path();
84            if path.extension().is_none_or(|e| e != "jsonl") {
85                continue;
86            }
87            let Ok(meta) = entry.metadata() else { continue };
88            let Ok(modified) = meta.modified() else {
89                continue;
90            };
91            let modified: DateTime<Utc> = modified.into();
92            let in_window = modified >= start && modified <= end;
93            let mentions_branch = std::fs::read_to_string(&path)
94                .map(|c| c.contains(branch))
95                .unwrap_or(false);
96            // spec heuristic: in the time window OR mentions the branch
97            if in_window || mentions_branch {
98                candidates.push((modified, path, in_window, mentions_branch));
99            }
100        }
101        candidates.sort_by(|a, b| b.0.cmp(&a.0)); // most recent first
102        candidates.truncate(max_sessions);
103        let mut out = Vec::new();
104        for (modified, path, in_window, mentions_branch) in candidates {
105            let text = read_tail_text(&path, max_kb * 1024)?;
106            if text.is_empty() {
107                continue;
108            }
109            let source = path
110                .file_name()
111                .map(|n| n.to_string_lossy().into_owned())
112                .unwrap_or_default();
113            out.push(SessionExcerpt {
114                source,
115                modified,
116                text,
117                in_window,
118                mentions_branch,
119            });
120        }
121        Ok(out)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use crate::sessions::SessionSource;
129    use chrono::{Duration, Utc};
130    use std::path::Path;
131
132    #[test]
133    fn encode_project_path_matches_claude_code_format() {
134        assert_eq!(
135            encode_project_path(Path::new("/home/g/repo/me/open-loops")),
136            "-home-g-repo-me-open-loops"
137        );
138        assert_eq!(
139            encode_project_path(Path::new("/home/g/my.app")),
140            "-home-g-my-app"
141        );
142    }
143
144    #[test]
145    fn extract_text_captures_user_assistant_and_ignores_rest() {
146        let user = r#"{"type":"user","message":{"content":"quero implementar login"}}"#;
147        let asst = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"vou criar feat/login"}]}}"#;
148        let meta = r#"{"type":"summary","summary":"x"}"#;
149        assert_eq!(
150            extract_text(user).unwrap(),
151            "[user] quero implementar login"
152        );
153        assert_eq!(
154            extract_text(asst).unwrap(),
155            "[assistant] vou criar feat/login"
156        );
157        assert!(extract_text(meta).is_none());
158        assert!(extract_text("corrupted non-json line").is_none());
159    }
160
161    #[test]
162    fn excerpts_selects_by_window_tolerates_garbage_and_limits_count() {
163        let tmp = tempfile::tempdir().unwrap();
164        let projects = tmp.path().to_path_buf();
165        let repo = Path::new("/home/g/app");
166        let dir = projects.join(encode_project_path(repo));
167        std::fs::create_dir_all(&dir).unwrap();
168        std::fs::write(
169            dir.join("sessao1.jsonl"),
170            concat!(
171                r#"{"type":"user","message":{"content":"quero implementar login"}}"#, "\n",
172                "lixo nao-json\n",
173                r#"{"type":"assistant","message":{"content":[{"type":"text","text":"proximo passo: validar token"}]}}"#, "\n",
174            ),
175        )
176        .unwrap();
177        // files of other formats are ignored
178        std::fs::write(dir.join("nota.txt"), "nada").unwrap();
179
180        let src = ClaudeCode {
181            projects_dir: projects,
182        };
183        let now = Utc::now();
184        let window = (now - Duration::days(1), now + Duration::days(1));
185        let ex = src.excerpts(repo, "feat/login", window, 3, 50).unwrap();
186        assert_eq!(ex.len(), 1);
187        assert!(ex[0].text.contains("[user] quero implementar login"));
188        assert!(ex[0].text.contains("proximo passo: validar token"));
189        assert_eq!(ex[0].source, "sessao1.jsonl");
190    }
191
192    #[test]
193    fn excerpts_empty_when_project_dir_does_not_exist() {
194        let tmp = tempfile::tempdir().unwrap();
195        let src = ClaudeCode {
196            projects_dir: tmp.path().to_path_buf(),
197        };
198        let now = Utc::now();
199        let ex = src
200            .excerpts(Path::new("/nao/existe"), "b", (now, now), 3, 50)
201            .unwrap();
202        assert!(ex.is_empty());
203    }
204
205    #[test]
206    fn excerpts_includes_session_outside_window_if_it_mentions_branch() {
207        let tmp = tempfile::tempdir().unwrap();
208        let projects = tmp.path().to_path_buf();
209        let repo = Path::new("/home/g/app");
210        let dir = projects.join(encode_project_path(repo));
211        std::fs::create_dir_all(&dir).unwrap();
212        std::fs::write(
213            dir.join("antiga.jsonl"),
214            concat!(
215                r#"{"type":"user","message":{"content":"implementando feat/login agora"}}"#,
216                "\n",
217            ),
218        )
219        .unwrap();
220
221        let src = ClaudeCode {
222            projects_dir: projects,
223        };
224        let now = Utc::now();
225        // window two years ago — file mtime is now (outside the window)
226        let passado = now - Duration::days(730);
227        let window = (passado - Duration::days(1), passado);
228        let ex = src.excerpts(repo, "feat/login", window, 3, 50).unwrap();
229        assert_eq!(ex.len(), 1, "mention heuristic must include the session");
230        assert!(ex[0].text.contains("feat/login"));
231    }
232
233    #[test]
234    fn excerpts_truncates_large_file_and_skips_cut_line() {
235        let tmp = tempfile::tempdir().unwrap();
236        let projects = tmp.path().to_path_buf();
237        let repo = Path::new("/home/g/app");
238        let dir = projects.join(encode_project_path(repo));
239        std::fs::create_dir_all(&dir).unwrap();
240
241        // padding with summary lines (not extracted) to force file > 1 KB
242        let pad_line = format!("{{\"type\":\"summary\",\"x\":\"{}\"}}\n", "A".repeat(80));
243        let mut content = pad_line.repeat(15); // ~1500 bytes
244        content.push_str(r#"{"type":"user","message":{"content":"contexto final"}}"#);
245        content.push('\n');
246        assert!(content.len() > 1024);
247
248        std::fs::write(dir.join("grande.jsonl"), &content).unwrap();
249
250        let src = ClaudeCode {
251            projects_dir: projects,
252        };
253        let now = Utc::now();
254        let window = (now - Duration::days(1), now + Duration::days(1));
255        // max_kb=1 forces truncation: start > 0 → first line of the tail is skipped
256        let ex = src.excerpts(repo, "feat/x", window, 3, 1).unwrap();
257        assert_eq!(ex.len(), 1);
258        assert!(ex[0].text.contains("contexto final"));
259    }
260
261    #[test]
262    fn excerpts_skips_session_with_only_messages_without_text() {
263        let tmp = tempfile::tempdir().unwrap();
264        let projects = tmp.path().to_path_buf();
265        let repo = Path::new("/home/g/app");
266        let dir = projects.join(encode_project_path(repo));
267        std::fs::create_dir_all(&dir).unwrap();
268        // only summary and tool_result lines — extract_text returns None for all of them
269        std::fs::write(
270            dir.join("vazia.jsonl"),
271            concat!(
272                r#"{"type":"summary","summary":"nada util"}"#,
273                "\n",
274                r#"{"type":"tool_result","content":[]}"#,
275                "\n",
276            ),
277        )
278        .unwrap();
279
280        let src = ClaudeCode {
281            projects_dir: projects,
282        };
283        let now = Utc::now();
284        let window = (now - Duration::days(1), now + Duration::days(1));
285        let ex = src.excerpts(repo, "feat/x", window, 3, 50).unwrap();
286        assert!(
287            ex.is_empty(),
288            "session with no extractable text must be skipped"
289        );
290    }
291}