Skip to main content

toolpath_claude/
derive.rs

1//! Derive Toolpath documents from Claude conversation logs.
2//!
3//! The conversation itself is treated as an artifact under change. Each turn
4//! appends to `claude://<session-id>` via a `conversation.append` structural
5//! operation. File mutations from tool use (Write, Edit) appear as sibling
6//! artifacts in the same step's `change` map.
7
8use crate::types::{ContentPart, Conversation, MessageContent, MessageRole};
9use serde_json::json;
10use std::collections::HashMap;
11use toolpath::v1::{
12    ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
13    StepIdentity, StructuralChange,
14};
15
16/// Configuration for deriving Toolpath documents from Claude conversations.
17#[derive(Default)]
18pub struct DeriveConfig {
19    /// Override the project path used for `path.base.uri`.
20    pub project_path: Option<String>,
21    /// Include thinking blocks in the conversation artifact.
22    pub include_thinking: bool,
23}
24
25/// Derive a single Toolpath Path from a Claude conversation.
26///
27/// The conversation is modeled as an artifact at `claude://<session-id>`.
28/// Each user or assistant turn produces a step whose `change` map contains
29/// a `conversation.append` structural change on that artifact, plus any
30/// file-level artifacts touched by tool use.
31pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
32    let session_short = safe_prefix(&conversation.session_id, 8);
33    let convo_artifact = format!("claude://{}", conversation.session_id);
34
35    let mut steps = Vec::new();
36    let mut last_step_id: Option<String> = None;
37    let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
38
39    for entry in &conversation.entries {
40        if entry.uuid.is_empty() {
41            continue;
42        }
43
44        let message = match &entry.message {
45            Some(m) => m,
46            None => continue,
47        };
48
49        let (actor, role_str) = match message.role {
50            MessageRole::User => {
51                actors
52                    .entry("human:user".to_string())
53                    .or_insert_with(|| ActorDefinition {
54                        name: Some("User".to_string()),
55                        ..Default::default()
56                    });
57                ("human:user".to_string(), "user")
58            }
59            MessageRole::Assistant => {
60                let (actor_key, model_str) = if let Some(model) = &message.model {
61                    (format!("agent:{}", model), model.clone())
62                } else {
63                    ("agent:claude-code".to_string(), "claude-code".to_string())
64                };
65                actors.entry(actor_key.clone()).or_insert_with(|| {
66                    let mut identities = vec![Identity {
67                        system: "anthropic".to_string(),
68                        id: model_str.clone(),
69                    }];
70                    if let Some(version) = &entry.version {
71                        identities.push(Identity {
72                            system: "claude-code".to_string(),
73                            id: version.clone(),
74                        });
75                    }
76                    ActorDefinition {
77                        name: Some("Claude Code".to_string()),
78                        provider: Some("anthropic".to_string()),
79                        model: Some(model_str),
80                        identities,
81                        ..Default::default()
82                    }
83                });
84                (actor_key, "assistant")
85            }
86            MessageRole::System => continue,
87        };
88
89        // Collect conversation text and file changes from this turn
90        let mut file_changes: HashMap<String, ArtifactChange> = HashMap::new();
91        let mut text_parts: Vec<String> = Vec::new();
92        let mut tool_uses: Vec<String> = Vec::new();
93
94        match &message.content {
95            Some(MessageContent::Parts(parts)) => {
96                for part in parts {
97                    match part {
98                        ContentPart::Text { text } => {
99                            if !text.trim().is_empty() {
100                                text_parts.push(text.clone());
101                            }
102                        }
103                        ContentPart::Thinking { thinking, .. } => {
104                            if config.include_thinking && !thinking.trim().is_empty() {
105                                text_parts.push(format!("[thinking] {}", thinking));
106                            }
107                        }
108                        ContentPart::ToolUse { name, input, .. } => {
109                            tool_uses.push(name.clone());
110                            if let Some(file_path) = input.get("file_path").and_then(|v| v.as_str())
111                            {
112                                match name.as_str() {
113                                    "Write" | "Edit" => {
114                                        file_changes.insert(
115                                            file_path.to_string(),
116                                            ArtifactChange {
117                                                raw: None,
118                                                structural: None,
119                                            },
120                                        );
121                                    }
122                                    _ => {}
123                                }
124                            }
125                        }
126                        _ => {}
127                    }
128                }
129            }
130            Some(MessageContent::Text(text)) => {
131                if !text.trim().is_empty() {
132                    text_parts.push(text.clone());
133                }
134            }
135            None => {}
136        }
137
138        // Skip entries with no conversation content and no file changes
139        if text_parts.is_empty() && tool_uses.is_empty() && file_changes.is_empty() {
140            continue;
141        }
142
143        // Build the conversation artifact change
144        let mut convo_extra = HashMap::new();
145        convo_extra.insert("role".to_string(), json!(role_str));
146        if !text_parts.is_empty() {
147            let combined = text_parts.join("\n\n");
148            convo_extra.insert("text".to_string(), json!(truncate(&combined, 2000)));
149        }
150        if !tool_uses.is_empty() {
151            convo_extra.insert("tool_uses".to_string(), json!(tool_uses.clone()));
152        }
153
154        let convo_change = ArtifactChange {
155            raw: None,
156            structural: Some(StructuralChange {
157                change_type: "conversation.append".to_string(),
158                extra: convo_extra,
159            }),
160        };
161
162        let mut changes = HashMap::new();
163        changes.insert(convo_artifact.clone(), convo_change);
164        changes.extend(file_changes);
165
166        // Build step — no meta.intent; the conversation content already
167        // lives in the structural change and adding it again is redundant.
168        let step_id = format!("step-{}", safe_prefix(&entry.uuid, 8));
169        let parents = if entry.is_sidechain {
170            entry
171                .parent_uuid
172                .as_ref()
173                .map(|p| vec![format!("step-{}", safe_prefix(p, 8))])
174                .unwrap_or_default()
175        } else {
176            last_step_id.iter().cloned().collect()
177        };
178
179        let step = Step {
180            step: StepIdentity {
181                id: step_id.clone(),
182                parents,
183                actor,
184                timestamp: entry.timestamp.clone(),
185            },
186            change: changes,
187            meta: None,
188        };
189
190        if !entry.is_sidechain {
191            last_step_id = Some(step_id);
192        }
193        steps.push(step);
194    }
195
196    let head = last_step_id.unwrap_or_else(|| "empty".to_string());
197    let base_uri = config
198        .project_path
199        .as_deref()
200        .or(conversation.project_path.as_deref())
201        .map(|p| format!("file://{}", p));
202
203    Path {
204        path: PathIdentity {
205            id: format!("path-claude-{}", session_short),
206            base: base_uri.map(|uri| Base { uri, ref_str: None }),
207            head,
208        },
209        steps,
210        meta: Some(PathMeta {
211            title: Some(format!("Claude session: {}", session_short)),
212            source: Some("claude-code".to_string()),
213            actors: if actors.is_empty() {
214                None
215            } else {
216                Some(actors)
217            },
218            ..Default::default()
219        }),
220    }
221}
222
223/// Derive Toolpath Paths from multiple conversations in a project.
224pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
225    conversations
226        .iter()
227        .map(|c| derive_path(c, config))
228        .collect()
229}
230
231/// Truncate a string to at most `max` characters (not bytes), appending "..."
232/// if truncated. Always cuts on a char boundary.
233fn truncate(s: &str, max: usize) -> String {
234    let char_count = s.chars().count();
235    if char_count <= max {
236        s.to_string()
237    } else {
238        let truncated: String = s.chars().take(max - 3).collect();
239        format!("{}...", truncated)
240    }
241}
242
243/// Return the first `n` characters of a string, safe for any UTF-8 content.
244fn safe_prefix(s: &str, n: usize) -> String {
245    s.chars().take(n).collect()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::types::{ContentPart, ConversationEntry, Message, MessageContent};
252
253    fn make_entry(
254        uuid: &str,
255        role: MessageRole,
256        content: &str,
257        timestamp: &str,
258    ) -> ConversationEntry {
259        ConversationEntry {
260            parent_uuid: None,
261            is_sidechain: false,
262            entry_type: match role {
263                MessageRole::User => "user",
264                MessageRole::Assistant => "assistant",
265                MessageRole::System => "system",
266            }
267            .to_string(),
268            uuid: uuid.to_string(),
269            timestamp: timestamp.to_string(),
270            session_id: Some("test-session".to_string()),
271            cwd: None,
272            git_branch: None,
273            version: None,
274            message: Some(Message {
275                role,
276                content: Some(MessageContent::Text(content.to_string())),
277                model: None,
278                id: None,
279                message_type: None,
280                stop_reason: None,
281                stop_sequence: None,
282                usage: None,
283            }),
284            user_type: None,
285            request_id: None,
286            tool_use_result: None,
287            snapshot: None,
288            message_id: None,
289            extra: Default::default(),
290        }
291    }
292
293    fn make_conversation(entries: Vec<ConversationEntry>) -> Conversation {
294        let mut convo = Conversation::new("test-session-12345678".to_string());
295        for entry in entries {
296            convo.add_entry(entry);
297        }
298        convo
299    }
300
301    // ── truncate ───────────────────────────────────────────────────────
302
303    #[test]
304    fn test_truncate_short() {
305        assert_eq!(truncate("hello", 10), "hello");
306    }
307
308    #[test]
309    fn test_truncate_exact() {
310        assert_eq!(truncate("hello", 5), "hello");
311    }
312
313    #[test]
314    fn test_truncate_long() {
315        let result = truncate("hello world, this is long", 10);
316        assert!(result.ends_with("..."));
317        assert_eq!(result.chars().count(), 10);
318    }
319
320    #[test]
321    fn test_truncate_multibyte() {
322        // Should not panic on multi-byte characters
323        let s = "café résumé naïve";
324        let result = truncate(s, 8);
325        assert!(result.ends_with("..."));
326        assert_eq!(result.chars().count(), 8);
327    }
328
329    // ── safe_prefix ────────────────────────────────────────────────────
330
331    #[test]
332    fn test_safe_prefix_normal() {
333        assert_eq!(safe_prefix("abcdef1234", 8), "abcdef12");
334    }
335
336    #[test]
337    fn test_safe_prefix_short() {
338        assert_eq!(safe_prefix("abc", 8), "abc");
339    }
340
341    #[test]
342    fn test_safe_prefix_unicode() {
343        assert_eq!(safe_prefix("日本語テスト", 3), "日本語");
344    }
345
346    // ── derive_path ────────────────────────────────────────────────────
347
348    #[test]
349    fn test_derive_path_basic() {
350        let entries = vec![
351            make_entry(
352                "uuid-1111-aaaa",
353                MessageRole::User,
354                "Hello",
355                "2024-01-01T00:00:00Z",
356            ),
357            make_entry(
358                "uuid-2222-bbbb",
359                MessageRole::Assistant,
360                "Hi there",
361                "2024-01-01T00:00:01Z",
362            ),
363        ];
364        let convo = make_conversation(entries);
365        let config = DeriveConfig::default();
366
367        let path = derive_path(&convo, &config);
368
369        assert!(path.path.id.starts_with("path-claude-"));
370        assert_eq!(path.steps.len(), 2);
371        assert_eq!(path.steps[0].step.actor, "human:user");
372        assert!(path.steps[1].step.actor.starts_with("agent:"));
373    }
374
375    #[test]
376    fn test_derive_path_step_parents() {
377        let entries = vec![
378            make_entry(
379                "uuid-1111",
380                MessageRole::User,
381                "Hello",
382                "2024-01-01T00:00:00Z",
383            ),
384            make_entry(
385                "uuid-2222",
386                MessageRole::Assistant,
387                "Hi",
388                "2024-01-01T00:00:01Z",
389            ),
390            make_entry(
391                "uuid-3333",
392                MessageRole::User,
393                "More",
394                "2024-01-01T00:00:02Z",
395            ),
396        ];
397        let convo = make_conversation(entries);
398        let config = DeriveConfig::default();
399
400        let path = derive_path(&convo, &config);
401
402        // Second step should have first as parent
403        assert!(path.steps[1].step.parents.contains(&path.steps[0].step.id));
404        // Third step should have second as parent
405        assert!(path.steps[2].step.parents.contains(&path.steps[1].step.id));
406    }
407
408    #[test]
409    fn test_derive_path_conversation_artifact() {
410        let entries = vec![make_entry(
411            "uuid-1111",
412            MessageRole::User,
413            "Hello",
414            "2024-01-01T00:00:00Z",
415        )];
416        let convo = make_conversation(entries);
417        let config = DeriveConfig::default();
418
419        let path = derive_path(&convo, &config);
420
421        // Each step should have the conversation artifact
422        let convo_key = format!("claude://{}", convo.session_id);
423        assert!(path.steps[0].change.contains_key(&convo_key));
424
425        let change = &path.steps[0].change[&convo_key];
426        let structural = change.structural.as_ref().unwrap();
427        assert_eq!(structural.change_type, "conversation.append");
428        assert_eq!(structural.extra["role"], "user");
429    }
430
431    #[test]
432    fn test_derive_path_no_meta_intent() {
433        let entries = vec![make_entry(
434            "uuid-1111",
435            MessageRole::User,
436            "Hello",
437            "2024-01-01T00:00:00Z",
438        )];
439        let convo = make_conversation(entries);
440        let config = DeriveConfig::default();
441
442        let path = derive_path(&convo, &config);
443
444        // meta.intent should NOT be set (we removed it as redundant)
445        assert!(path.steps[0].meta.is_none());
446    }
447
448    #[test]
449    fn test_derive_path_actors() {
450        let entries = vec![
451            make_entry(
452                "uuid-1111",
453                MessageRole::User,
454                "Hello",
455                "2024-01-01T00:00:00Z",
456            ),
457            make_entry(
458                "uuid-2222",
459                MessageRole::Assistant,
460                "Hi",
461                "2024-01-01T00:00:01Z",
462            ),
463        ];
464        let convo = make_conversation(entries);
465        let config = DeriveConfig::default();
466
467        let path = derive_path(&convo, &config);
468        let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
469
470        assert!(actors.contains_key("human:user"));
471        // Assistant actor depends on model (None in our test)
472        assert!(actors.contains_key("agent:claude-code"));
473    }
474
475    #[test]
476    fn test_derive_path_with_project_path_config() {
477        let convo = make_conversation(vec![make_entry(
478            "uuid-1",
479            MessageRole::User,
480            "Hello",
481            "2024-01-01T00:00:00Z",
482        )]);
483        let config = DeriveConfig {
484            project_path: Some("/my/project".to_string()),
485            ..Default::default()
486        };
487
488        let path = derive_path(&convo, &config);
489        assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///my/project");
490    }
491
492    #[test]
493    fn test_derive_path_skips_empty_content() {
494        let mut entry = make_entry("uuid-1111", MessageRole::User, "", "2024-01-01T00:00:00Z");
495        // Empty text, no tool uses, no file changes → should be skipped
496        entry.message.as_mut().unwrap().content = Some(MessageContent::Text("   ".to_string()));
497
498        let convo = make_conversation(vec![entry]);
499        let config = DeriveConfig::default();
500
501        let path = derive_path(&convo, &config);
502        assert!(path.steps.is_empty());
503    }
504
505    #[test]
506    fn test_derive_path_skips_system_messages() {
507        let entries = vec![
508            make_entry(
509                "uuid-1111",
510                MessageRole::System,
511                "System prompt",
512                "2024-01-01T00:00:00Z",
513            ),
514            make_entry(
515                "uuid-2222",
516                MessageRole::User,
517                "Hello",
518                "2024-01-01T00:00:01Z",
519            ),
520        ];
521        let convo = make_conversation(entries);
522        let config = DeriveConfig::default();
523
524        let path = derive_path(&convo, &config);
525        // System message should be skipped
526        assert_eq!(path.steps.len(), 1);
527        assert_eq!(path.steps[0].step.actor, "human:user");
528    }
529
530    #[test]
531    fn test_derive_path_with_tool_use() {
532        let mut convo = Conversation::new("test-session-12345678".to_string());
533        let entry = ConversationEntry {
534            parent_uuid: None,
535            is_sidechain: false,
536            entry_type: "assistant".to_string(),
537            uuid: "uuid-tool".to_string(),
538            timestamp: "2024-01-01T00:00:00Z".to_string(),
539            session_id: Some("test-session".to_string()),
540            message: Some(Message {
541                role: MessageRole::Assistant,
542                content: Some(MessageContent::Parts(vec![
543                    ContentPart::Text {
544                        text: "Let me write that".to_string(),
545                    },
546                    ContentPart::ToolUse {
547                        id: "t1".to_string(),
548                        name: "Write".to_string(),
549                        input: serde_json::json!({"file_path": "/tmp/test.rs"}),
550                    },
551                ])),
552                model: Some("claude-sonnet-4-5-20250929".to_string()),
553                id: None,
554                message_type: None,
555                stop_reason: None,
556                stop_sequence: None,
557                usage: None,
558            }),
559            cwd: None,
560            git_branch: None,
561            version: None,
562            user_type: None,
563            request_id: None,
564            tool_use_result: None,
565            snapshot: None,
566            message_id: None,
567            extra: Default::default(),
568        };
569        convo.add_entry(entry);
570        let config = DeriveConfig::default();
571
572        let path = derive_path(&convo, &config);
573
574        assert_eq!(path.steps.len(), 1);
575        // Should have both the conversation artifact and the file artifact
576        assert!(path.steps[0].change.contains_key("/tmp/test.rs"));
577        let convo_key = format!("claude://{}", convo.session_id);
578        assert!(path.steps[0].change.contains_key(&convo_key));
579    }
580
581    #[test]
582    fn test_derive_path_sidechain_uses_parent_uuid() {
583        let mut convo = Conversation::new("test-session-12345678".to_string());
584
585        let e1 = make_entry(
586            "uuid-main-11",
587            MessageRole::User,
588            "Hello",
589            "2024-01-01T00:00:00Z",
590        );
591        let e2 = make_entry(
592            "uuid-main-22",
593            MessageRole::Assistant,
594            "Hi",
595            "2024-01-01T00:00:01Z",
596        );
597        let mut e3 = make_entry(
598            "uuid-side-33",
599            MessageRole::User,
600            "Side",
601            "2024-01-01T00:00:02Z",
602        );
603        e3.is_sidechain = true;
604        e3.parent_uuid = Some("uuid-main-11".to_string());
605
606        convo.add_entry(e1);
607        convo.add_entry(e2);
608        convo.add_entry(e3);
609
610        let config = DeriveConfig::default();
611        let path = derive_path(&convo, &config);
612
613        assert_eq!(path.steps.len(), 3);
614        // Sidechain step should reference e1 as parent, not e2
615        let sidechain_step = &path.steps[2];
616        let expected_parent = format!("step-{}", safe_prefix("uuid-main-11", 8));
617        assert!(sidechain_step.step.parents.contains(&expected_parent));
618    }
619
620    // ── derive_project ─────────────────────────────────────────────────
621
622    #[test]
623    fn test_derive_project() {
624        let c1 = make_conversation(vec![make_entry(
625            "uuid-1",
626            MessageRole::User,
627            "Hello",
628            "2024-01-01T00:00:00Z",
629        )]);
630        let mut c2 = Conversation::new("session-2".to_string());
631        c2.add_entry(make_entry(
632            "uuid-2",
633            MessageRole::User,
634            "World",
635            "2024-01-02T00:00:00Z",
636        ));
637
638        let config = DeriveConfig::default();
639        let paths = derive_project(&[c1, c2], &config);
640
641        assert_eq!(paths.len(), 2);
642    }
643
644    #[test]
645    fn test_derive_path_head_is_last_non_sidechain() {
646        let entries = vec![
647            make_entry(
648                "uuid-1111",
649                MessageRole::User,
650                "Hello",
651                "2024-01-01T00:00:00Z",
652            ),
653            make_entry(
654                "uuid-2222",
655                MessageRole::Assistant,
656                "Hi",
657                "2024-01-01T00:00:01Z",
658            ),
659        ];
660        let convo = make_conversation(entries);
661        let config = DeriveConfig::default();
662
663        let path = derive_path(&convo, &config);
664
665        // Head should point to the last step
666        assert_eq!(path.path.head, path.steps.last().unwrap().step.id);
667    }
668}