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 machine_id: crate::storage::get_machine_id(),
294 };
295
296 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 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 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 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 assert!(session.started_at.timestamp_millis() == 1704067200000);
537
538 assert!(session.ended_at.is_some());
540 assert!(session.ended_at.unwrap().timestamp_millis() == 1704067260000);
541
542 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}