lean_ctx/tools/registered/
ctx_checkpoint.rs1use 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
15pub 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}