Skip to main content

lean_ctx/tools/registered/
ctx_checkpoint.rs

1use std::path::Path;
2
3use rmcp::model::Tool;
4use rmcp::ErrorData;
5use serde_json::{json, Map, Value};
6
7use crate::core::git::shadow::{self, Checkpoint};
8use crate::core::tokens::count_tokens;
9use crate::server::tool_trait::{get_int, get_str, McpTool, ToolContext, ToolOutput};
10use crate::tool_defs::tool_def;
11
12const DEFAULT_LOG_LIMIT: usize = 20;
13const DIFF_MAX_TOKENS: usize = 8000;
14
15/// `ctx_checkpoint` — snapshot, review, diff, and revert the agent's code
16/// changes in a shadow git history kept outside the user's own `.git`.
17pub struct CtxCheckpointTool;
18
19impl McpTool for CtxCheckpointTool {
20    fn name(&self) -> &'static str {
21        "ctx_checkpoint"
22    }
23
24    fn tool_def(&self) -> Tool {
25        tool_def(
26            "ctx_checkpoint",
27            "Local shadow git history of the agent's changes (separate from the user's .git).\n\
28             actions: snapshot (record current state) | log (list checkpoints) | diff (vs a checkpoint) | restore (revert files).\n\
29             Snapshot before+after a change to capture exactly what the LLM modified; diff/restore to review or roll back.\n\
30             Never touches the user's repository.",
31            json!({
32                "type": "object",
33                "properties": {
34                    "action": {
35                        "type": "string",
36                        "enum": ["snapshot", "log", "diff", "restore"],
37                        "description": "Operation to perform (default: log)"
38                    },
39                    "message": { "type": "string", "description": "Snapshot label (snapshot)" },
40                    "from": { "type": "string", "description": "Base checkpoint sha (diff)" },
41                    "to": { "type": "string", "description": "Target checkpoint sha (diff; default: working tree)" },
42                    "ref": { "type": "string", "description": "Checkpoint sha to restore from (restore)" },
43                    "path": { "type": "string", "description": "Limit restore to this file/dir (restore)" },
44                    "limit": { "type": "integer", "description": "Max checkpoints to list (log, default: 20)" }
45                }
46            }),
47        )
48    }
49
50    fn handle(
51        &self,
52        args: &Map<String, Value>,
53        ctx: &ToolContext,
54    ) -> Result<ToolOutput, ErrorData> {
55        let action = get_str(args, "action").unwrap_or_else(|| "log".to_string());
56        let project = Path::new(&ctx.project_root).to_path_buf();
57        if ctx.project_root.is_empty() {
58            return Err(ErrorData::invalid_params(
59                "no project root resolved for ctx_checkpoint",
60                None,
61            ));
62        }
63
64        let result = tokio::task::block_in_place(|| match action.as_str() {
65            "snapshot" => {
66                let msg = get_str(args, "message").unwrap_or_default();
67                shadow::snapshot(&project, &msg).map(|c| render_checkpoint_line("Checkpoint", &c))
68            }
69            "log" => {
70                let limit =
71                    get_int(args, "limit").map_or(DEFAULT_LOG_LIMIT, |n| n.clamp(1, 200) as usize);
72                shadow::log(&project, limit).map(|cs| render_log(&cs))
73            }
74            "diff" => {
75                let from = get_str(args, "from");
76                let to = get_str(args, "to");
77                shadow::diff(&project, from.as_deref(), to.as_deref())
78                    .map(|d| budget(&d, DIFF_MAX_TOKENS))
79            }
80            "restore" => {
81                let git_ref = get_str(args, "ref").ok_or_else(|| {
82                    "restore requires 'ref' (a checkpoint sha from log)".to_string()
83                })?;
84                let path = get_str(args, "path");
85                shadow::restore(&project, &git_ref, path.as_deref())
86            }
87            other => Err(format!(
88                "invalid action '{other}' (use: snapshot, log, diff, restore)"
89            )),
90        });
91
92        match result {
93            Ok(text) => Ok(ToolOutput {
94                text,
95                original_tokens: 0,
96                saved_tokens: 0,
97                mode: Some(action),
98                path: None,
99                changed: matches!(
100                    args.get("action").and_then(Value::as_str),
101                    Some("snapshot" | "restore")
102                ),
103                shell_outcome: None,
104            }),
105            Err(e) => Err(ErrorData::invalid_params(
106                format!("ctx_checkpoint failed: {e}"),
107                None,
108            )),
109        }
110    }
111}
112
113fn render_checkpoint_line(prefix: &str, c: &Checkpoint) -> String {
114    let files = c
115        .files_changed
116        .map(|n| format!(" · {n} file(s)"))
117        .unwrap_or_default();
118    format!("{prefix} {}{files} — {}", c.sha, c.message)
119}
120
121fn render_log(checkpoints: &[Checkpoint]) -> String {
122    if checkpoints.is_empty() {
123        return "No checkpoints yet. Run `ctx_checkpoint` with action=snapshot to record one."
124            .to_string();
125    }
126    let mut out = format!("{} checkpoint(s):\n", checkpoints.len());
127    for c in checkpoints {
128        out.push_str(&format!("- {} · {} — {}\n", c.sha, c.time, c.message));
129    }
130    out.trim_end().to_string()
131}
132
133fn budget(content: &str, max_tokens: usize) -> String {
134    if content.trim().is_empty() {
135        return "No differences.".to_string();
136    }
137    let tokens = count_tokens(content);
138    if tokens <= max_tokens {
139        return content.to_string();
140    }
141    let ratio = max_tokens as f64 / tokens as f64;
142    let keep = ((content.chars().count() as f64 * ratio) as usize).max(1);
143    let truncated: String = content.chars().take(keep).collect();
144    format!("{truncated}\n\n…[diff truncated to ~{max_tokens} tokens]")
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn cp(sha: &str, msg: &str, files: Option<usize>) -> Checkpoint {
152        Checkpoint {
153            sha: sha.to_string(),
154            time: "2026-06-07T10:00:00Z".to_string(),
155            message: msg.to_string(),
156            files_changed: files,
157        }
158    }
159
160    #[test]
161    fn renders_empty_log_with_hint() {
162        assert!(render_log(&[]).contains("snapshot"));
163    }
164
165    #[test]
166    fn renders_checkpoint_line_with_file_count() {
167        let line = render_checkpoint_line("Checkpoint", &cp("abc1234", "fix bug", Some(3)));
168        assert_eq!(line, "Checkpoint abc1234 · 3 file(s) — fix bug");
169    }
170
171    #[test]
172    fn renders_log_entries() {
173        let out = render_log(&[cp("a1", "one", None), cp("b2", "two", None)]);
174        assert!(out.contains("2 checkpoint(s)"));
175        assert!(out.contains("- a1 ·"));
176        assert!(out.contains("- b2 ·"));
177    }
178
179    #[test]
180    fn budget_handles_empty_diff() {
181        assert_eq!(budget("", 100), "No differences.");
182    }
183}