Skip to main content

devboy_core/agents/
kimi.rs

1//! Kimi Code CLI detector.
2//!
3//! Storage layout (from `MoonshotAI/kimi-cli/src/kimi_cli/{share,metadata,session}.py`):
4//! ```text
5//! $KIMI_SHARE_DIR | ~/.kimi/
6//! └── sessions/<md5(work_dir_path)>/<session_uuid>/
7//!     ├── context.jsonl      ← message history
8//!     ├── state.json         ← SessionState
9//!     └── subagents/<id>/
10//! ```
11//!
12//! Sessions = count of `<uuid>/context.jsonl` across all workdir buckets.
13//! last_used = max mtime of those files.
14
15use 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        // Empty session — no context.jsonl, should not count
134        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}