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        machine_id: crate::storage::get_machine_id(),
294    };
295
296    // Convert messages
297    let mut messages = Vec::new();
298    let time_per_message = chrono::Duration::seconds(30);
299    let mut current_time = started_at;
300
301    for (idx, msg) in raw_messages.iter().enumerate() {
302        let role = match msg.role.as_str() {
303            "user" => MessageRole::User,
304            "assistant" => MessageRole::Assistant,
305            "system" => MessageRole::System,
306            _ => continue,
307        };
308
309        let content_text = msg.content.to_text();
310        if content_text.trim().is_empty() {
311            continue;
312        }
313
314        let timestamp = msg
315            .ts
316            .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
317            .unwrap_or(current_time);
318
319        messages.push(Message {
320            id: Uuid::new_v4(),
321            session_id,
322            parent_id: None,
323            index: idx as i32,
324            timestamp,
325            role,
326            content: MessageContent::Text(content_text),
327            model: None,
328            git_branch: None,
329            cwd: Some(session.working_directory.clone()),
330        });
331
332        current_time += time_per_message;
333    }
334
335    if messages.is_empty() {
336        return Ok(None);
337    }
338
339    Ok(Some((session, messages)))
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::io::Write;
346    use tempfile::{NamedTempFile, TempDir};
347
348    /// Creates a temporary Kilo Code conversation file with given JSON content.
349    fn create_temp_conversation_file(json: &str) -> NamedTempFile {
350        let mut file = NamedTempFile::with_suffix(".json").expect("Failed to create temp file");
351        file.write_all(json.as_bytes())
352            .expect("Failed to write content");
353        file.flush().expect("Failed to flush");
354        file
355    }
356
357    /// Creates a temporary task directory structure.
358    fn create_temp_task_dir(task_id: &str, history_json: &str) -> TempDir {
359        let temp_dir = TempDir::new().expect("Failed to create temp dir");
360        let task_dir = temp_dir.path().join(task_id);
361        fs::create_dir_all(&task_dir).expect("Failed to create task dir");
362
363        let history_file = task_dir.join("api_conversation_history.json");
364        fs::write(&history_file, history_json).expect("Failed to write history file");
365
366        temp_dir
367    }
368
369    #[test]
370    fn test_watcher_info() {
371        let watcher = KiloCodeWatcher;
372        let info = watcher.info();
373
374        assert_eq!(info.name, "kilo-code");
375        assert_eq!(info.description, "Kilo Code VS Code extension sessions");
376    }
377
378    #[test]
379    fn test_watcher_watch_paths() {
380        let watcher = KiloCodeWatcher;
381        let paths = watcher.watch_paths();
382
383        assert!(!paths.is_empty());
384        assert!(paths[0].to_string_lossy().contains("kilocode.Kilo-Code"));
385        assert!(paths[0].to_string_lossy().contains("tasks"));
386    }
387
388    #[test]
389    fn test_parse_simple_conversation() {
390        let json = r#"[
391            {"role": "user", "content": "Hello, can you help me?", "ts": 1704067200000},
392            {"role": "assistant", "content": "Of course! What do you need?", "ts": 1704067230000}
393        ]"#;
394
395        let file = create_temp_conversation_file(json);
396        let result = parse_kilo_code_task(file.path()).expect("Should parse");
397
398        let (session, messages) = result.expect("Should have session");
399        assert_eq!(session.tool, "kilo-code");
400        assert_eq!(messages.len(), 2);
401        assert_eq!(messages[0].role, MessageRole::User);
402        assert_eq!(messages[1].role, MessageRole::Assistant);
403    }
404
405    #[test]
406    fn test_parse_with_content_blocks() {
407        let json = r#"[
408            {
409                "role": "user",
410                "content": [
411                    {"type": "text", "text": "Hello"},
412                    {"type": "text", "text": "World"}
413                ],
414                "ts": 1704067200000
415            }
416        ]"#;
417
418        let file = create_temp_conversation_file(json);
419        let result = parse_kilo_code_task(file.path()).expect("Should parse");
420
421        let (_, messages) = result.expect("Should have session");
422        assert_eq!(messages.len(), 1);
423        if let MessageContent::Text(text) = &messages[0].content {
424            assert!(text.contains("Hello"));
425            assert!(text.contains("World"));
426        } else {
427            panic!("Expected text content");
428        }
429    }
430
431    #[test]
432    fn test_parse_empty_conversation() {
433        let json = "[]";
434
435        let file = create_temp_conversation_file(json);
436        let result = parse_kilo_code_task(file.path()).expect("Should parse");
437
438        assert!(result.is_none());
439    }
440
441    #[test]
442    fn test_parse_with_tool_blocks() {
443        let json = r#"[
444            {
445                "role": "user",
446                "content": "Create a file",
447                "ts": 1704067200000
448            },
449            {
450                "role": "assistant",
451                "content": [
452                    {"type": "text", "text": "I'll create that file."},
453                    {"type": "tool_use", "id": "tool_1", "name": "write_file", "input": {"path": "test.txt"}}
454                ],
455                "ts": 1704067230000
456            }
457        ]"#;
458
459        let file = create_temp_conversation_file(json);
460        let result = parse_kilo_code_task(file.path()).expect("Should parse");
461
462        let (_, messages) = result.expect("Should have session");
463        assert_eq!(messages.len(), 2);
464    }
465
466    #[test]
467    fn test_parse_filters_empty_content() {
468        let json = r#"[
469            {"role": "user", "content": "Hello", "ts": 1704067200000},
470            {"role": "assistant", "content": "", "ts": 1704067230000}
471        ]"#;
472
473        let file = create_temp_conversation_file(json);
474        let result = parse_kilo_code_task(file.path()).expect("Should parse");
475
476        let (_, messages) = result.expect("Should have session");
477        // Empty content should be filtered
478        assert_eq!(messages.len(), 1);
479    }
480
481    #[test]
482    fn test_find_tasks_returns_ok_when_dir_missing() {
483        let result = find_kilo_code_tasks();
484        assert!(result.is_ok());
485    }
486
487    #[test]
488    fn test_watcher_parse_source() {
489        let watcher = KiloCodeWatcher;
490        let json = r#"[{"role": "user", "content": "Test", "ts": 1704067200000}]"#;
491
492        let file = create_temp_conversation_file(json);
493        let result = watcher
494            .parse_source(file.path())
495            .expect("Should parse successfully");
496
497        assert!(!result.is_empty());
498        let (session, _) = &result[0];
499        assert_eq!(session.tool, "kilo-code");
500    }
501
502    #[test]
503    fn test_parse_with_task_directory() {
504        let json = r#"[
505            {"role": "user", "content": "Hello", "ts": 1704067200000}
506        ]"#;
507
508        let temp_dir = create_temp_task_dir("550e8400-e29b-41d4-a716-446655440000", json);
509        let history_path = temp_dir
510            .path()
511            .join("550e8400-e29b-41d4-a716-446655440000")
512            .join("api_conversation_history.json");
513
514        let result = parse_kilo_code_task(&history_path).expect("Should parse");
515
516        let (session, _) = result.expect("Should have session");
517        assert_eq!(
518            session.id.to_string(),
519            "550e8400-e29b-41d4-a716-446655440000"
520        );
521    }
522
523    #[test]
524    fn test_timestamps_from_messages() {
525        let json = r#"[
526            {"role": "user", "content": "First", "ts": 1704067200000},
527            {"role": "assistant", "content": "Second", "ts": 1704067260000}
528        ]"#;
529
530        let file = create_temp_conversation_file(json);
531        let result = parse_kilo_code_task(file.path()).expect("Should parse");
532
533        let (session, messages) = result.expect("Should have session");
534
535        // started_at should be from first message
536        assert!(session.started_at.timestamp_millis() == 1704067200000);
537
538        // ended_at should be from last message
539        assert!(session.ended_at.is_some());
540        assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
541
542        // Message timestamps should match
543        assert!(messages[0].timestamp.timestamp_millis() == 1704067200000);
544        assert!(messages[1].timestamp.timestamp_millis() == 1704067260000);
545    }
546
547    #[test]
548    fn test_handles_unknown_role() {
549        let json = r#"[
550            {"role": "user", "content": "Hello", "ts": 1704067200000},
551            {"role": "unknown", "content": "Should be skipped", "ts": 1704067230000}
552        ]"#;
553
554        let file = create_temp_conversation_file(json);
555        let result = parse_kilo_code_task(file.path()).expect("Should parse");
556
557        let (_, messages) = result.expect("Should have session");
558        assert_eq!(messages.len(), 1);
559    }
560}