lore_cli/capture/watchers/
kilo_code.rs

1//! Kilo Code session parser.
2//!
3//! Parses conversation history from Kilo Code, an AI coding assistant VS Code
4//! extension that is a fork of Roo Code.
5//!
6//! Kilo Code stores task conversations in the VS Code global storage directory:
7//! - macOS: `~/Library/Application Support/Code/User/globalStorage/kilocode.Kilo-Code/tasks/`
8//! - Linux: `~/.config/Code/User/globalStorage/kilocode.Kilo-Code/tasks/`
9//! - Windows: `%APPDATA%/Code/User/globalStorage/kilocode.Kilo-Code/tasks/`
10//!
11//! Each task has its own directory containing:
12//! - `api_conversation_history.json` - Raw API message exchanges
13//! - `ui_messages.json` - User-facing message format
14//! - `task_metadata.json` - Task metadata (optional)
15
16use anyhow::{Context, Result};
17use chrono::{DateTime, TimeZone, Utc};
18use serde::Deserialize;
19use std::fs;
20use std::path::{Path, PathBuf};
21use uuid::Uuid;
22
23use crate::storage::models::{Message, MessageContent, MessageRole, Session};
24
25use super::{Watcher, WatcherInfo};
26
27/// Watcher for Kilo Code sessions.
28///
29/// Discovers and parses task conversation files from Kilo Code's VS Code
30/// extension storage.
31pub struct KiloCodeWatcher;
32
33impl Watcher for KiloCodeWatcher {
34    fn info(&self) -> WatcherInfo {
35        WatcherInfo {
36            name: "kilo-code",
37            description: "Kilo Code VS Code extension sessions",
38            default_paths: vec![kilo_code_tasks_path()],
39        }
40    }
41
42    fn is_available(&self) -> bool {
43        kilo_code_tasks_path().exists()
44    }
45
46    fn find_sources(&self) -> Result<Vec<PathBuf>> {
47        find_kilo_code_tasks()
48    }
49
50    fn parse_source(&self, path: &Path) -> Result<Vec<(Session, Vec<Message>)>> {
51        let parsed = parse_kilo_code_task(path)?;
52        match parsed {
53            Some((session, messages)) if !messages.is_empty() => Ok(vec![(session, messages)]),
54            _ => Ok(vec![]),
55        }
56    }
57
58    fn watch_paths(&self) -> Vec<PathBuf> {
59        vec![kilo_code_tasks_path()]
60    }
61}
62
63/// Returns the path to Kilo Code's tasks storage directory.
64///
65/// This is platform-specific:
66/// - macOS: `~/Library/Application Support/Code/User/globalStorage/kilocode.Kilo-Code/tasks`
67/// - Linux: `~/.config/Code/User/globalStorage/kilocode.Kilo-Code/tasks`
68/// - Windows: `%APPDATA%/Code/User/globalStorage/kilocode.Kilo-Code/tasks`
69fn kilo_code_tasks_path() -> PathBuf {
70    let base = get_vscode_global_storage();
71    base.join("kilocode.Kilo-Code").join("tasks")
72}
73
74/// Returns the VS Code global storage path.
75fn get_vscode_global_storage() -> PathBuf {
76    #[cfg(target_os = "macos")]
77    {
78        dirs::home_dir()
79            .unwrap_or_else(|| PathBuf::from("."))
80            .join("Library/Application Support/Code/User/globalStorage")
81    }
82    #[cfg(target_os = "linux")]
83    {
84        dirs::config_dir()
85            .unwrap_or_else(|| PathBuf::from("."))
86            .join("Code/User/globalStorage")
87    }
88    #[cfg(target_os = "windows")]
89    {
90        dirs::config_dir()
91            .unwrap_or_else(|| PathBuf::from("."))
92            .join("Code/User/globalStorage")
93    }
94    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
95    {
96        dirs::config_dir()
97            .unwrap_or_else(|| PathBuf::from("."))
98            .join("Code/User/globalStorage")
99    }
100}
101
102/// Finds all Kilo Code task directories.
103///
104/// Each task has its own subdirectory containing conversation files.
105fn find_kilo_code_tasks() -> Result<Vec<PathBuf>> {
106    let tasks_path = kilo_code_tasks_path();
107
108    if !tasks_path.exists() {
109        return Ok(Vec::new());
110    }
111
112    let mut tasks = Vec::new();
113
114    for entry in fs::read_dir(&tasks_path)? {
115        let entry = entry?;
116        let path = entry.path();
117
118        if path.is_dir() {
119            // Check for conversation history file
120            let history_file = path.join("api_conversation_history.json");
121            if history_file.exists() {
122                tasks.push(history_file);
123            }
124        }
125    }
126
127    Ok(tasks)
128}
129
130/// Raw Kilo Code API conversation message.
131/// Uses the same format as Roo Code and Cline.
132#[derive(Debug, Deserialize)]
133struct KiloCodeApiMessage {
134    /// Role: "user" or "assistant"
135    role: String,
136
137    /// Message content (can be string or array of content blocks)
138    content: KiloCodeContent,
139
140    /// Timestamp (milliseconds since epoch)
141    #[serde(default)]
142    ts: Option<i64>,
143}
144
145/// Content in Kilo Code API format.
146/// Uses the same format as Roo Code and Cline.
147#[derive(Debug, Deserialize)]
148#[serde(untagged)]
149enum KiloCodeContent {
150    /// Simple text content
151    Text(String),
152    /// Array of content blocks
153    Blocks(Vec<KiloCodeContentBlock>),
154}
155
156impl KiloCodeContent {
157    /// Extracts text content from the message.
158    fn to_text(&self) -> String {
159        match self {
160            Self::Text(s) => s.clone(),
161            Self::Blocks(blocks) => blocks
162                .iter()
163                .filter_map(|b| match b {
164                    KiloCodeContentBlock::Text { text } => Some(text.clone()),
165                    _ => None,
166                })
167                .collect::<Vec<_>>()
168                .join("\n"),
169        }
170    }
171}
172
173/// A content block in Kilo Code messages.
174/// Uses the same format as Roo Code and Cline.
175#[derive(Debug, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum KiloCodeContentBlock {
178    /// Text content
179    Text { text: String },
180    /// Image content
181    Image {
182        #[allow(dead_code)]
183        source: serde_json::Value,
184    },
185    /// Tool use
186    ToolUse {
187        #[allow(dead_code)]
188        id: Option<String>,
189        #[allow(dead_code)]
190        name: Option<String>,
191        #[allow(dead_code)]
192        input: Option<serde_json::Value>,
193    },
194    /// Tool result
195    ToolResult {
196        #[allow(dead_code)]
197        tool_use_id: Option<String>,
198        #[allow(dead_code)]
199        content: Option<serde_json::Value>,
200    },
201}
202
203/// Task metadata from Kilo Code.
204/// Uses the same format as Roo Code and Cline.
205#[derive(Debug, Deserialize, Default)]
206struct KiloCodeTaskMetadata {
207    /// Timestamp (ISO 8601 or milliseconds)
208    #[serde(default)]
209    ts: Option<serde_json::Value>,
210
211    /// Working directory
212    #[serde(default)]
213    dir: Option<String>,
214}
215
216/// Parses a Kilo Code task from its conversation history file.
217fn parse_kilo_code_task(history_path: &Path) -> Result<Option<(Session, Vec<Message>)>> {
218    let content = fs::read_to_string(history_path)
219        .context("Failed to read Kilo Code conversation history")?;
220
221    let raw_messages: Vec<KiloCodeApiMessage> =
222        serde_json::from_str(&content).context("Failed to parse Kilo Code conversation JSON")?;
223
224    if raw_messages.is_empty() {
225        return Ok(None);
226    }
227
228    // Try to get task directory for ID and metadata
229    let task_dir = history_path.parent();
230    let task_id = task_dir
231        .and_then(|p| p.file_name())
232        .and_then(|n| n.to_str())
233        .map(|s| s.to_string());
234
235    // Try to read metadata
236    let metadata = task_dir
237        .map(|d| d.join("task_metadata.json"))
238        .filter(|p| p.exists())
239        .and_then(|p| fs::read_to_string(p).ok())
240        .and_then(|c| serde_json::from_str::<KiloCodeTaskMetadata>(&c).ok())
241        .unwrap_or_default();
242
243    // Generate session ID from task ID or create new one
244    let session_id = task_id
245        .as_ref()
246        .and_then(|id| Uuid::parse_str(id).ok())
247        .unwrap_or_else(Uuid::new_v4);
248
249    // Determine timestamps
250    let first_ts = raw_messages.first().and_then(|m| m.ts);
251    let last_ts = raw_messages.last().and_then(|m| m.ts);
252
253    let started_at = first_ts
254        .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
255        .or_else(|| {
256            metadata.ts.as_ref().and_then(|v| match v {
257                serde_json::Value::Number(n) => n
258                    .as_i64()
259                    .and_then(|ts| Utc.timestamp_millis_opt(ts).single()),
260                serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
261                    .ok()
262                    .map(|dt| dt.with_timezone(&Utc)),
263                _ => None,
264            })
265        })
266        .unwrap_or_else(Utc::now);
267
268    let ended_at = last_ts.and_then(|ts| Utc.timestamp_millis_opt(ts).single());
269
270    // Get working directory
271    let working_directory = metadata
272        .dir
273        .or_else(|| {
274            task_dir
275                .and_then(|d| d.parent())
276                .and_then(|d| d.parent())
277                .and_then(|d| d.parent())
278                .map(|d| d.to_string_lossy().to_string())
279        })
280        .unwrap_or_else(|| ".".to_string());
281
282    let session = Session {
283        id: session_id,
284        tool: "kilo-code".to_string(),
285        tool_version: None,
286        started_at,
287        ended_at,
288        model: None,
289        working_directory,
290        git_branch: None,
291        source_path: Some(history_path.to_string_lossy().to_string()),
292        message_count: raw_messages.len() as i32,
293    };
294
295    // Convert messages
296    let mut messages = Vec::new();
297    let time_per_message = chrono::Duration::seconds(30);
298    let mut current_time = started_at;
299
300    for (idx, msg) in raw_messages.iter().enumerate() {
301        let role = match msg.role.as_str() {
302            "user" => MessageRole::User,
303            "assistant" => MessageRole::Assistant,
304            "system" => MessageRole::System,
305            _ => continue,
306        };
307
308        let content_text = msg.content.to_text();
309        if content_text.trim().is_empty() {
310            continue;
311        }
312
313        let timestamp = msg
314            .ts
315            .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
316            .unwrap_or(current_time);
317
318        messages.push(Message {
319            id: Uuid::new_v4(),
320            session_id,
321            parent_id: None,
322            index: idx as i32,
323            timestamp,
324            role,
325            content: MessageContent::Text(content_text),
326            model: None,
327            git_branch: None,
328            cwd: Some(session.working_directory.clone()),
329        });
330
331        current_time += time_per_message;
332    }
333
334    if messages.is_empty() {
335        return Ok(None);
336    }
337
338    Ok(Some((session, messages)))
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use std::io::Write;
345    use tempfile::{NamedTempFile, TempDir};
346
347    /// Creates a temporary Kilo Code conversation file with given JSON content.
348    fn create_temp_conversation_file(json: &str) -> NamedTempFile {
349        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
350        file.write_all(json.as_bytes())
351            .expect("Failed to write content");
352        file.flush().expect("Failed to flush");
353        file
354    }
355
356    /// Creates a temporary task directory structure.
357    fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
358        let temp_dir = TempDir::new().expect("Failed to create temp dir");
359        let task_dir = temp_dir.path().join(task_id);
360        fs::create_dir_all(&task_dir).expect("Failed to create task dir");
361
362        let history_file = task_dir.join("api_conversation_history.json");
363        fs::write(&history_file, history_json).expect("Failed to write history file");
364
365        temp_dir
366    }
367
368    #[test]
369    fn test_watcher_info() {
370        let watcher = KiloCodeWatcher;
371        let info = watcher.info();
372
373        assert_eq!(info.name, "kilo-code");
374        assert_eq!(info.description, "Kilo Code VS Code extension sessions");
375    }
376
377    #[test]
378    fn test_watcher_watch_paths() {
379        let watcher = KiloCodeWatcher;
380        let paths = watcher.watch_paths();
381
382        assert!(!paths.is_empty());
383        assert!(paths[0].to_string_lossy().contains("kilocode.Kilo-Code"));
384        assert!(paths[0].to_string_lossy().contains("tasks"));
385    }
386
387    #[test]
388    fn test_parse_simple_conversation() {
389        let json = r#"[
390            {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
391            {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
392        ]"#;
393
394        let file = create_temp_conversation_file(json);
395        let result = parse_kilo_code_task(file.path()).expect("Should parse");
396
397        let (session, messages) = result.expect("Should have session");
398        assert_eq!(session.tool, "kilo-code");
399        assert_eq!(messages.len(), 2);
400        assert_eq!(messages[0].role, MessageRole::User);
401        assert_eq!(messages[1].role, MessageRole::Assistant);
402    }
403
404    #[test]
405    fn test_parse_with_content_blocks() {
406        let json = r#"[
407            {
408                "role": "user",
409                "content": [
410                    {"type": "text", "text": "Hello"},
411                    {"type": "text", "text": "World"}
412                ],
413                "ts": 1704067200000
414            }
415        ]"#;
416
417        let file = create_temp_conversation_file(json);
418        let result = parse_kilo_code_task(file.path()).expect("Should parse");
419
420        let (_, messages) = result.expect("Should have session");
421        assert_eq!(messages.len(), 1);
422        if let MessageContent::Text(text) = &messages[0].content {
423            assert!(text.contains("Hello"));
424            assert!(text.contains("World"));
425        } else {
426            panic!("Expected text content");
427        }
428    }
429
430    #[test]
431    fn test_parse_empty_conversation() {
432        let json = "[]";
433
434        let file = create_temp_conversation_file(json);
435        let result = parse_kilo_code_task(file.path()).expect("Should parse");
436
437        assert!(result.is_none());
438    }
439
440    #[test]
441    fn test_parse_with_tool_blocks() {
442        let json = r#"[
443            {
444                "role": "user",
445                "content": "Create a file",
446                "ts": 1704067200000
447            },
448            {
449                "role": "assistant",
450                "content": [
451                    {"type": "text", "text": "I'll create that file."},
452                    {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
453                ],
454                "ts": 1704067230000
455            }
456        ]"#;
457
458        let file = create_temp_conversation_file(json);
459        let result = parse_kilo_code_task(file.path()).expect("Should parse");
460
461        let (_, messages) = result.expect("Should have session");
462        assert_eq!(messages.len(), 2);
463    }
464
465    #[test]
466    fn test_parse_filters_empty_content() {
467        let json = r#"[
468            {"role": "user", "content": "Hello", "ts": 1704067200000},
469            {"role": "assistant", "content": "", "ts": 1704067230000}
470        ]"#;
471
472        let file = create_temp_conversation_file(json);
473        let result = parse_kilo_code_task(file.path()).expect("Should parse");
474
475        let (_, messages) = result.expect("Should have session");
476        // Empty content should be filtered
477        assert_eq!(messages.len(), 1);
478    }
479
480    #[test]
481    fn test_find_tasks_returns_ok_when_dir_missing() {
482        let result = find_kilo_code_tasks();
483        assert!(result.is_ok());
484    }
485
486    #[test]
487    fn test_watcher_parse_source() {
488        let watcher = KiloCodeWatcher;
489        let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
490
491        let file = create_temp_conversation_file(json);
492        let result = watcher
493            .parse_source(file.path())
494            .expect("Should parse successfully");
495
496        assert!(!result.is_empty());
497        let (session, _) = &result[0];
498        assert_eq!(session.tool, "kilo-code");
499    }
500
501    #[test]
502    fn test_parse_with_task_directory() {
503        let json = r#"[
504            {"role": "user", "content": "Hello", "ts": 1704067200000}
505        ]"#;
506
507        let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
508        let history_path = temp_dir
509            .path()
510            .join("550e8400-e29b-41d4-a716-446655440000")
511            .join("api_conversation_history.json");
512
513        let result = parse_kilo_code_task(&history_path).expect("Should parse");
514
515        let (session, _) = result.expect("Should have session");
516        assert_eq!(
517            session.id.to_string(),
518            "550e8400-e29b-41d4-a716-446655440000"
519        );
520    }
521
522    #[test]
523    fn test_timestamps_from_messages() {
524        let json = r#"[
525            {"role": "user", "content": "First", "ts": 1704067200000},
526            {"role": "assistant", "content": "Second", "ts": 1704067260000}
527        ]"#;
528
529        let file = create_temp_conversation_file(json);
530        let result = parse_kilo_code_task(file.path()).expect("Should parse");
531
532        let (session, messages) = result.expect("Should have session");
533
534        // started_at should be from first message
535        assert!(session.started_at.timestamp_millis() == 1704067200000);
536
537        // ended_at should be from last message
538        assert!(session.ended_at.is_some());
539        assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
540
541        // Message timestamps should match
542        assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
543        assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
544    }
545
546    #[test]
547    fn test_handles_unknown_role() {
548        let json = r#"[
549            {"role": "user", "content": "Hello", "ts": 1704067200000},
550            {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
551        ]"#;
552
553        let file = create_temp_conversation_file(json);
554        let result = parse_kilo_code_task(file.path()).expect("Should parse");
555
556        let (_, messages) = result.expect("Should have session");
557        assert_eq!(messages.len(), 1);
558    }
559}