devboy_core/agents/
kimi.rs1use std::path::Path;
16
17use super::fs_util::to_utc;
18use super::{AgentDetector, AgentSnapshot, InstallStatus};
19
20const ID: &str = "kimi";
21const DISPLAY_NAME: &str = "Kimi Code CLI";
22const MAX_SESSIONS: usize = 50_000;
23
24pub struct KimiDetector;
25
26impl AgentDetector for KimiDetector {
27 fn id(&self) -> &'static str {
28 ID
29 }
30 fn display_name(&self) -> &'static str {
31 DISPLAY_NAME
32 }
33
34 fn detect(&self, home: &Path) -> AgentSnapshot {
35 let share_dir = std::env::var_os("KIMI_SHARE_DIR")
36 .map(std::path::PathBuf::from)
37 .unwrap_or_else(|| home.join(".kimi"));
38 let sessions_dir = share_dir.join("sessions");
39 let paths_checked = vec![share_dir.clone(), sessions_dir.clone()];
40
41 let dir_present = share_dir.is_dir();
42 let binary_present = which::which("kimi").is_ok();
43
44 let status = if dir_present || binary_present {
45 InstallStatus::Yes
46 } else {
47 InstallStatus::No
48 };
49 if status == InstallStatus::No {
50 return empty(paths_checked);
51 }
52
53 let (count, last_used) = walk_sessions(&sessions_dir);
54
55 AgentSnapshot {
56 id: ID,
57 display_name: DISPLAY_NAME,
58 status,
59 sessions: (count > 0).then_some(count),
60 last_used,
61 score: 0.0,
62 paths_checked,
63 }
64 }
65}
66
67fn walk_sessions(sessions_dir: &Path) -> (u64, Option<chrono::DateTime<chrono::Utc>>) {
68 let Ok(workdir_buckets) = std::fs::read_dir(sessions_dir) else {
69 return (0, None);
70 };
71 let mut count = 0u64;
72 let mut best: Option<chrono::DateTime<chrono::Utc>> = None;
73 for bucket in workdir_buckets.flatten() {
74 let bucket_path = bucket.path();
75 if !bucket_path.is_dir() {
76 continue;
77 }
78 let Ok(session_uuids) = std::fs::read_dir(&bucket_path) else {
79 continue;
80 };
81 for session in session_uuids.flatten() {
82 if count as usize >= MAX_SESSIONS {
83 return (count, best);
84 }
85 let session_path = session.path();
86 if !session_path.is_dir() {
87 continue;
88 }
89 let context = session_path.join("context.jsonl");
90 if !context.exists() {
91 continue;
92 }
93 count += 1;
94 if let Ok(meta) = context.metadata()
95 && let Ok(t) = meta.modified()
96 && let Some(t) = to_utc(t)
97 {
98 best = Some(best.map_or(t, |b| b.max(t)));
99 }
100 }
101 }
102 (count, best)
103}
104
105fn empty(paths_checked: Vec<std::path::PathBuf>) -> AgentSnapshot {
106 AgentSnapshot {
107 id: ID,
108 display_name: DISPLAY_NAME,
109 status: InstallStatus::No,
110 sessions: None,
111 last_used: None,
112 score: 0.0,
113 paths_checked,
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use std::fs;
121 use tempfile::tempdir;
122
123 #[test]
124 fn counts_context_jsonl_under_md5_buckets() {
125 let home = tempdir().unwrap();
126 let kimi = home.path().join(".kimi/sessions");
127 let bucket1 = kimi.join("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
128 let bucket2 = kimi.join("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
129 fs::create_dir_all(bucket1.join("uuid-1")).unwrap();
130 fs::write(bucket1.join("uuid-1/context.jsonl"), b"{}\n").unwrap();
131 fs::create_dir_all(bucket1.join("uuid-2")).unwrap();
132 fs::write(bucket1.join("uuid-2/context.jsonl"), b"{}\n").unwrap();
133 fs::create_dir_all(bucket1.join("uuid-empty")).unwrap();
135 fs::create_dir_all(bucket2.join("uuid-3")).unwrap();
136 fs::write(bucket2.join("uuid-3/context.jsonl"), b"{}\n").unwrap();
137
138 let snap = KimiDetector.detect(home.path());
139 assert_eq!(snap.status, InstallStatus::Yes);
140 assert_eq!(snap.sessions, Some(3));
141 }
142
143 #[test]
144 fn no_kimi_dir_means_not_installed() {
145 let home = tempdir().unwrap();
146 let snap = KimiDetector.detect(home.path());
147 if which::which("kimi").is_err() {
148 assert_eq!(snap.status, InstallStatus::No);
149 assert!(snap.sessions.is_none());
150 }
151 }
152
153 #[test]
154 fn kimi_dir_without_sessions_still_reports_install() {
155 let home = tempdir().unwrap();
156 fs::create_dir_all(home.path().join(".kimi")).unwrap();
157 let snap = KimiDetector.detect(home.path());
158 assert_eq!(snap.status, InstallStatus::Yes);
159 assert!(snap.sessions.is_none());
160 }
161}