lean_ctx_server/tools/registered/
ctx_transcript_compact.rs1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5use std::time::Duration;
6
7use crate::server::tool_trait::{McpTool, ToolContext, ToolOutput, get_str, get_usize};
8use crate::tools::ctx_transcript_compact::{compact_messages, render_result, serialize_transcript};
9use lean_ctx::tool_defs::tool_def;
10
11const DEFAULT_FRESH_TAIL_TOKENS: usize = 4_000;
12const DEFAULT_PROTECT_MIN_MESSAGES: usize = 6;
13const OFFLOAD_MAX_CHARS: usize = 8_000;
14const SESSION_LOCK_BUDGET: Duration = Duration::from_millis(250);
15
16fn session_matches_snapshot(
17 live: &lean_ctx::core::session::SessionState,
18 snapshot: &lean_ctx::core::session::SessionState,
19) -> bool {
20 live.id == snapshot.id
21 && live.version == snapshot.version
22 && live.project_root == snapshot.project_root
23 && live.shell_cwd == snapshot.shell_cwd
24 && live.extra_roots == snapshot.extra_roots
25}
26
27pub struct CtxTranscriptCompactTool;
28
29impl McpTool for CtxTranscriptCompactTool {
30 fn name(&self) -> &'static str {
31 "ctx_transcript_compact"
32 }
33
34 fn tool_def(&self) -> Tool {
35 tool_def(
36 "ctx_transcript_compact",
37 "Compact an OpenAI-format message array deterministically:\n\
38 keep system + fresh tail verbatim, replace older turns with a recoverable\n\
39 summary, offload raw turns into session memory (indexed for recall).\n\
40 Returns JSON {messages, stats}. tool_call/tool_result pairs never split.",
41 json!({
42 "type": "object",
43 "properties": {
44 "messages": {
45 "type": "array",
46 "items": { "type": "object" },
47 "description": "OpenAI message array to compact"
48 },
49 "fresh_tail_tokens": {
50 "type": "integer",
51 "description": "Recent tokens to keep verbatim"
52 },
53 "protect_min_messages": {
54 "type": "integer",
55 "description": "Minimum recent messages to keep"
56 },
57 "focus_topic": {
58 "type": "string",
59 "description": "Topic to prioritise in summary"
60 }
61 },
62 "required": ["messages"]
63 }),
64 )
65 }
66
67 fn handle(
68 &self,
69 args: &Map<String, Value>,
70 ctx: &ToolContext,
71 ) -> Result<ToolOutput, ErrorData> {
72 let messages = args
73 .get("messages")
74 .and_then(Value::as_array)
75 .ok_or_else(|| ErrorData::invalid_params("messages (array) is required", None))?;
76 let fresh_tail = get_usize(args, "fresh_tail_tokens").unwrap_or(DEFAULT_FRESH_TAIL_TOKENS);
77 let protect_min =
78 get_usize(args, "protect_min_messages").unwrap_or(DEFAULT_PROTECT_MIN_MESSAGES);
79 let focus = get_str(args, "focus_topic");
80
81 let result = compact_messages(messages.clone(), fresh_tail, protect_min, focus.as_deref());
82
83 let offload_target = if result.did_compact && !result.summarized.is_empty() {
86 ctx.session.as_ref()
87 } else {
88 None
89 };
90 if let Some(session_handle) = offload_target {
91 let digest = serialize_transcript(&result.summarized, OFFLOAD_MAX_CHARS);
92 if !digest.is_empty() {
93 if let Some(snapshot) = crate::server::bounded_lock::read_for(
94 session_handle,
95 "transcript compact session snapshot",
96 SESSION_LOCK_BUDGET,
97 )
98 .map(|session| session.clone())
99 {
100 if let Ok(prepared) = crate::tools::ctx_session::prepare_admitted_action(
101 &snapshot,
102 &[],
103 "finding",
104 Some(&digest),
105 None,
106 crate::tools::ctx_session::SessionToolOptions {
107 format: None,
108 path: None,
109 write: false,
110 privacy: None,
111 terse: Some(true),
112 agent_id: None,
113 },
114 ) {
115 if let Some(mut live) = crate::server::bounded_lock::write_for(
116 session_handle,
117 "transcript compact session commit",
118 SESSION_LOCK_BUDGET,
119 ) {
120 if session_matches_snapshot(&live, &snapshot)
121 && prepared.store_admission.is_current()
122 {
123 *live = prepared.candidate;
124 }
125 }
126 }
127 }
128 }
129 }
130
131 let saved = result
132 .original_tokens
133 .saturating_sub(result.compacted_tokens);
134 Ok(ToolOutput {
135 text: render_result(&result),
136 original_tokens: result.original_tokens,
137 saved_tokens: saved,
138 mode: Some("transcript_compact".to_string()),
139 path: None,
140 changed: false,
141 shell_outcome: None,
142 content_blocks: None,
143 })
144 }
145}