Skip to main content

sessionwiki/adapters/
claude_code.rs

1use super::{
2    clean_path, dedup_paths, ok_or_flag, parse_ts, redacted_truncate, title_from_messages, Adapter,
3    Discovered,
4};
5use crate::model::{Message, Role, Session};
6use crate::util::short_id;
7use anyhow::Result;
8use serde_json::Value;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12/// Claude Code stores one JSONL file per session under
13/// `~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl`.
14/// Each line is an event: user/assistant messages, tool results,
15/// summaries, and harness bookkeeping.
16///
17/// `ClaudeCode::default()` reads the stock `~/.claude` install. An embedder
18/// that runs several installs in different homes builds one adapter per
19/// install with [`ClaudeCode::in_home`].
20#[derive(Default)]
21pub struct ClaudeCode {
22    /// Explicit projects directory, or `None` for the stock location.
23    root: Option<PathBuf>,
24}
25
26impl ClaudeCode {
27    /// An adapter for the Claude Code install rooted at `home` (e.g.
28    /// `~/.claude4`). The projects sub-directory layout is the adapter's
29    /// business, not the caller's.
30    pub fn in_home(home: impl Into<PathBuf>) -> Self {
31        ClaudeCode {
32            root: Some(home.into().join("projects")),
33        }
34    }
35}
36
37impl Adapter for ClaudeCode {
38    fn name(&self) -> &'static str {
39        "claude-code"
40    }
41
42    fn root(&self) -> Option<PathBuf> {
43        match &self.root {
44            Some(root) => Some(root.clone()),
45            None => Some(dirs::home_dir()?.join(".claude").join("projects")),
46        }
47    }
48
49    /// An explicit install speaks only for the rows under its own root; the
50    /// stock adapter speaks for every Claude Code row, as before.
51    fn reconcile_scope(&self) -> Option<String> {
52        super::root_scope(self.root.as_deref())
53    }
54
55    fn discover(&self) -> Discovered {
56        // Main sessions live at <project>/<uuid>.jsonl; subagent transcripts
57        // at <project>/<uuid>/subagents/agent-*.jsonl and nest further when
58        // subagents spawn subagents, so no depth limit here.
59        let Some(root) = self.root() else {
60            return Vec::new().into();
61        };
62        if !root.exists() {
63            return Vec::new().into(); // no store on this machine - normal
64        }
65        let mut had_error = false;
66        let files = WalkDir::new(root)
67            .into_iter()
68            .filter_map(|e| ok_or_flag(e, &mut had_error))
69            .filter(|e| e.file_type().is_file())
70            .filter(|e| e.path().extension().is_some_and(|x| x == "jsonl"))
71            .map(|e| e.into_path())
72            .collect();
73        Discovered { files, had_error }
74    }
75
76    fn parse(&self, path: &Path) -> Result<Session> {
77        // A session over the byte cap is WINDOWED (head+tail) rather than
78        // dropped, so the biggest sessions - the ones most worth referencing -
79        // stay searchable and partially readable.
80        let (lines, windowed) = crate::util::session_lines(path)?;
81
82        let mut messages: Vec<Message> = Vec::new();
83        let mut touched: Vec<String> = Vec::new();
84        let mut edits: Vec<crate::model::EditEvent> = Vec::new();
85        let mut cwd: Option<String> = None;
86        let mut summary: Option<String> = None;
87        let mut ai_title: Option<String> = None;
88        let mut started = None;
89        let mut ended = None;
90
91        for line in &lines {
92            let Ok(v) = serde_json::from_str::<Value>(line) else {
93                continue;
94            };
95
96            if cwd.is_none() {
97                if let Some(c) = v.get("cwd").and_then(Value::as_str) {
98                    cwd = Some(c.to_string());
99                }
100            }
101            if let Some(ts) = v
102                .get("timestamp")
103                .and_then(Value::as_str)
104                .and_then(parse_ts)
105            {
106                if started.is_none() {
107                    started = Some(ts);
108                }
109                ended = Some(ts);
110            }
111
112            match v.get("type").and_then(Value::as_str) {
113                Some("summary") => {
114                    if summary.is_none() {
115                        summary = v.get("summary").and_then(Value::as_str).map(String::from);
116                    }
117                }
118                Some("ai-title") => {
119                    if ai_title.is_none() {
120                        ai_title = v.get("aiTitle").and_then(Value::as_str).map(String::from);
121                    }
122                }
123                Some("user") => {
124                    // Skip harness meta lines; keep real prompts and tool results.
125                    if v.get("isMeta").and_then(Value::as_bool) == Some(true) {
126                        continue;
127                    }
128                    let ts = v
129                        .get("timestamp")
130                        .and_then(Value::as_str)
131                        .and_then(parse_ts);
132                    let Some(content) = v.pointer("/message/content") else {
133                        continue;
134                    };
135                    match content {
136                        Value::String(s) => push(&mut messages, Role::User, s, ts),
137                        Value::Array(blocks) => {
138                            for b in blocks {
139                                match b.get("type").and_then(Value::as_str) {
140                                    Some("text") => {
141                                        if let Some(t) = b.get("text").and_then(Value::as_str) {
142                                            push(&mut messages, Role::User, t, ts);
143                                        }
144                                    }
145                                    Some("tool_result") => {
146                                        let t = block_text(b.get("content"));
147                                        if !t.is_empty() {
148                                            push(
149                                                &mut messages,
150                                                Role::Tool,
151                                                &redacted_truncate(&t, 500),
152                                                ts,
153                                            );
154                                        }
155                                    }
156                                    _ => {}
157                                }
158                            }
159                        }
160                        _ => {}
161                    }
162                }
163                Some("assistant") => {
164                    let ts = v
165                        .get("timestamp")
166                        .and_then(Value::as_str)
167                        .and_then(parse_ts);
168                    let Some(Value::Array(blocks)) = v.pointer("/message/content") else {
169                        continue;
170                    };
171                    for b in blocks {
172                        match b.get("type").and_then(Value::as_str) {
173                            Some("text") => {
174                                if let Some(t) = b.get("text").and_then(Value::as_str) {
175                                    push(&mut messages, Role::Assistant, t, ts);
176                                }
177                            }
178                            Some("tool_use") => {
179                                let name = b.get("name").and_then(Value::as_str).unwrap_or("?");
180                                if let Some(ev) = edit_event(name, b.get("input"), ts) {
181                                    touched.push(ev.path.clone());
182                                    edits.push(ev);
183                                }
184                                let input =
185                                    b.get("input").map(|i| i.to_string()).unwrap_or_default();
186                                let text = format!("{name} {}", redacted_truncate(&input, 300));
187                                push(&mut messages, Role::Tool, &text, ts);
188                            }
189                            _ => {}
190                        }
191                    }
192                }
193                _ => {}
194            }
195        }
196
197        let project = cwd.unwrap_or_else(|| {
198            // Fall back to the sanitized directory name.
199            path.parent()
200                .and_then(|p| p.file_name())
201                .map(|n| n.to_string_lossy().into_owned())
202                .unwrap_or_default()
203        });
204        let base_title = summary
205            .or(ai_title)
206            .map(|s| redacted_truncate(&s, 80))
207            .unwrap_or_else(|| title_from_messages(&messages));
208        // Flag a windowed session so search/list/window make the partial read obvious.
209        let title = if windowed {
210            format!("[large] {base_title}")
211        } else {
212            base_title
213        };
214        let subagent = path.to_string_lossy().contains("/subagents/");
215
216        Ok(Session {
217            id: short_id(&path.to_string_lossy()),
218            tool: self.name(),
219            path: path.to_path_buf(),
220            project,
221            started,
222            ended,
223            title,
224            subagent,
225            messages,
226            touched: dedup_paths(touched),
227            edits,
228        })
229    }
230}
231
232/// Pull the file a Claude Code edit tool acted on from its `input`. Only the
233/// tools that write to disk count; reads, searches and shell commands do not
234/// establish authorship. The field name varies by tool (`file_path`,
235/// `notebook_path`, or the generic `path`).
236fn edited_path(name: &str, input: Option<&Value>) -> Option<String> {
237    let writes = matches!(
238        name,
239        "Edit" | "Write" | "MultiEdit" | "NotebookEdit" | "str_replace_based_edit_tool"
240    );
241    if !writes {
242        return None;
243    }
244    let input = input?;
245    for key in ["file_path", "notebook_path", "path"] {
246        if let Some(p) = input.get(key).and_then(Value::as_str) {
247            if !p.is_empty() {
248                return Some(p.to_string());
249            }
250        }
251    }
252    None
253}
254
255const SNIPPET_CAP: usize = 200;
256
257/// Extract the structured edit evidence from one write tool call - the richer
258/// sibling of `edited_path`: not just WHICH file, but what kind of change and a
259/// bounded snippet of it. Returns None for non-write tools.
260fn edit_event(
261    name: &str,
262    input: Option<&Value>,
263    ts: Option<chrono::DateTime<chrono::Utc>>,
264) -> Option<crate::model::EditEvent> {
265    use crate::model::{EditEvent, EditKind};
266    // clean_path applies the SAME hygiene dedup_paths gives `touched`, so an
267    // edits row never survives for a path touched would have dropped.
268    let path = clean_path(&edited_path(name, input)?)?;
269    let input = input?;
270    // Every name edited_path() accepts must map here, so `edits` and `touched`
271    // never diverge (a touched path with no evidence, or vice versa).
272    let kind = match name {
273        "Edit" | "str_replace_based_edit_tool" => EditKind::Edit,
274        "Write" => EditKind::Write,
275        "MultiEdit" => EditKind::MultiEdit,
276        "NotebookEdit" => EditKind::NotebookEdit,
277        _ => return None,
278    };
279    let raw = match kind {
280        // str_replace_based_edit_tool names the field new_str / file_text.
281        EditKind::Edit => str_field(input, &["new_string", "new_str", "file_text"]),
282        EditKind::Write => str_field(input, &["content"]),
283        EditKind::MultiEdit => input
284            .get("edits")
285            .and_then(Value::as_array)
286            .and_then(|a| a.first())
287            .and_then(|e| e.get("new_string").and_then(Value::as_str))
288            .unwrap_or(""),
289        EditKind::NotebookEdit => str_field(input, &["new_source"]),
290    };
291    let snippet = redacted_truncate(raw.trim(), SNIPPET_CAP);
292    Some(EditEvent {
293        path,
294        kind,
295        snippet,
296        ts,
297    })
298}
299
300/// First present string among `keys`, or "".
301fn str_field<'a>(v: &'a Value, keys: &[&str]) -> &'a str {
302    keys.iter()
303        .find_map(|k| v.get(k).and_then(Value::as_str))
304        .unwrap_or("")
305}
306
307fn push(
308    messages: &mut Vec<Message>,
309    role: Role,
310    text: &str,
311    ts: Option<chrono::DateTime<chrono::Utc>>,
312) {
313    let text = text.trim();
314    if !text.is_empty() {
315        messages.push(Message {
316            role,
317            text: text.to_string(),
318            ts,
319        });
320    }
321}
322
323/// tool_result content is either a string or an array of text blocks.
324fn block_text(content: Option<&Value>) -> String {
325    match content {
326        Some(Value::String(s)) => s.clone(),
327        Some(Value::Array(blocks)) => blocks
328            .iter()
329            .filter_map(|b| b.get("text").and_then(Value::as_str))
330            .collect::<Vec<_>>()
331            .join(" "),
332        _ => String::new(),
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::model::EditKind;
340    use serde_json::json;
341
342    #[test]
343    fn edit_event_captures_kind_and_new_string_snippet() {
344        let input = json!({
345            "file_path": "/repo/src/auth.rs",
346            "old_string": "let x = 1;",
347            "new_string": "let x = 2; // fixed",
348        });
349        let ev = edit_event("Edit", Some(&input), None).expect("Edit yields an edit event");
350        assert_eq!(ev.path, "/repo/src/auth.rs");
351        assert_eq!(ev.kind, EditKind::Edit);
352        assert!(
353            ev.snippet.contains("let x = 2;"),
354            "snippet should show the new code, got: {:?}",
355            ev.snippet
356        );
357    }
358
359    #[test]
360    fn write_event_snippet_comes_from_content() {
361        let input = json!({ "file_path": "/repo/new.rs", "content": "fn main() {}\n" });
362        let ev = edit_event("Write", Some(&input), None).expect("Write yields an edit event");
363        assert_eq!(ev.kind, EditKind::Write);
364        assert!(ev.snippet.contains("fn main()"), "got: {:?}", ev.snippet);
365    }
366
367    #[test]
368    fn multiedit_event_snippet_comes_from_first_edit() {
369        let input = json!({
370            "file_path": "/repo/a.rs",
371            "edits": [
372                { "old_string": "a", "new_string": "alpha" },
373                { "old_string": "b", "new_string": "beta" },
374            ],
375        });
376        let ev =
377            edit_event("MultiEdit", Some(&input), None).expect("MultiEdit yields an edit event");
378        assert_eq!(ev.kind, EditKind::MultiEdit);
379        assert!(ev.snippet.contains("alpha"), "got: {:?}", ev.snippet);
380    }
381
382    #[test]
383    fn non_write_tools_are_not_edit_events() {
384        let input = json!({ "command": "ls", "description": "list" });
385        assert!(edit_event("Bash", Some(&input), None).is_none());
386        assert!(edit_event("Read", Some(&json!({ "file_path": "/x" })), None).is_none());
387    }
388
389    #[test]
390    fn edit_event_cleans_the_path_like_touched() {
391        // Whitespace is trimmed so edits.path matches the dedup_paths-cleaned
392        // touched path (they must not diverge).
393        let ev = edit_event(
394            "Edit",
395            Some(&json!({ "file_path": "  /repo/a.rs  ", "new_string": "x" })),
396            None,
397        )
398        .expect("a trimmable path still yields an edit");
399        assert_eq!(ev.path, "/repo/a.rs");
400        // A path the touched filter rejects (embedded newline) yields no edit
401        // either - never an edits-only record touched would have dropped.
402        assert!(edit_event(
403            "Edit",
404            Some(&json!({ "file_path": "/re\npo/a.rs", "new_string": "x" })),
405            None
406        )
407        .is_none());
408    }
409
410    #[test]
411    fn parse_populates_edits_alongside_touched() {
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("sess.jsonl");
414        let line = r#"{"type":"assistant","timestamp":"2026-07-01T10:00:00Z","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/auth.rs","old_string":"a","new_string":"let fixed = true;"}}]}}"#;
415        std::fs::write(&path, format!("{line}\n")).unwrap();
416
417        let session = ClaudeCode::default().parse(&path).unwrap();
418
419        assert_eq!(session.edits.len(), 1, "one edit event extracted");
420        assert_eq!(session.edits[0].path, "/repo/src/auth.rs");
421        assert_eq!(session.edits[0].kind, EditKind::Edit);
422        assert!(session.edits[0].snippet.contains("let fixed = true;"));
423        // touched stays consistent with edits (same path).
424        assert_eq!(session.touched, vec!["/repo/src/auth.rs".to_string()]);
425    }
426
427    /// An embedder points one adapter at each Claude Code install. The adapter
428    /// must find that install's transcripts and claim only that install's rows
429    /// for deletion reconciliation.
430    #[test]
431    fn in_home_discovers_that_installs_sessions_and_scopes_reconciliation() {
432        let home = tempfile::tempdir().unwrap();
433        let project = home.path().join("projects").join("-repo");
434        std::fs::create_dir_all(&project).unwrap();
435        let file = project.join("11111111-2222-3333-4444-555555555555.jsonl");
436        std::fs::write(&file, "{\"type\":\"user\",\"cwd\":\"/repo\"}\n").unwrap();
437
438        let adapter = ClaudeCode::in_home(home.path());
439        let found = adapter.discover();
440        assert!(!found.had_error);
441        assert_eq!(found.files, vec![file.clone()]);
442
443        let scope = adapter
444            .reconcile_scope()
445            .expect("an explicit install is scoped");
446        assert!(
447            file.to_string_lossy().starts_with(&scope),
448            "{scope} must be a prefix of the discovered {}",
449            file.display()
450        );
451        assert_eq!(
452            ClaudeCode::default().reconcile_scope(),
453            None,
454            "the stock adapter still speaks for every claude-code row"
455        );
456    }
457}