Skip to main content

lean_ctx/tools/registered/
ctx_checkpoint.rs

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