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