Skip to main content

lean_ctx_server/tools/registered/
ctx_transcript_compact.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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        // Offload only through the same detached admission and optimistic
84        // commit path used by direct session actions.
85        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}