lore_cli/capture/watchers/
kilo_code.rs1use 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
27pub 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
63fn kilo_code_tasks_path() -> PathBuf {
70 let base = get_vscode_global_storage();
71 base.join("kilocode.Kilo-Code").join("tasks")
72}
73
74fn 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
102fn 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 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#[derive(Debug, Deserialize)]
133struct KiloCodeApiMessage {
134 role: String,
136
137 content: KiloCodeContent,
139
140 #[serde(default)]
142 ts: Option<i64>,
143}
144
145#[derive(Debug, Deserialize)]
148#[serde(untagged)]
149enum KiloCodeContent {
150 Text(String),
152 Blocks(Vec<KiloCodeContentBlock>),
154}
155
156impl KiloCodeContent {
157 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#[derive(Debug, Deserialize)]
176#[serde(tag = "type", rename_all = "snake_case")]
177enum KiloCodeContentBlock {
178 Text { text: String },
180 Image {
182 #[allow(dead_code)]
183 source: serde_json::Value,
184 },
185 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 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#[derive(Debug, Deserialize, Default)]
206struct KiloCodeTaskMetadata {
207 #[serde(default)]
209 ts: Option<serde_json::Value>,
210
211 #[serde(default)]
213 dir: Option<String>,
214}
215
216fn 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 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 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 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 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 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 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 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 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 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 assert!(session.started_at.timestamp_millis() == 1704067200000);
536
537 assert!(session.ended_at.is_some());
539 assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
540
541 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}