1use std::sync::Arc;
2
3use rmcp::handler::server::ServerHandler;
4use rmcp::model::*;
5use rmcp::service::{RequestContext, RoleServer};
6use rmcp::ErrorData;
7use serde_json::{json, Map, Value};
8
9use crate::tools::{CrpMode, LeanCtxServer};
10
11impl ServerHandler for LeanCtxServer {
15 fn get_info(&self) -> ServerInfo {
16 let capabilities = ServerCapabilities::builder().enable_tools().build();
17
18 let instructions = build_instructions(self.crp_mode);
19
20 InitializeResult::new(capabilities)
21 .with_server_info(Implementation::new("lean-ctx", "2.12.3"))
22 .with_instructions(instructions)
23 }
24
25 async fn initialize(
26 &self,
27 request: InitializeRequestParams,
28 _context: RequestContext<RoleServer>,
29 ) -> Result<InitializeResult, ErrorData> {
30 let name = request.client_info.name.clone();
31 tracing::info!("MCP client connected: {:?}", name);
32 *self.client_name.write().await = name.clone();
33
34 tokio::task::spawn_blocking(|| {
35 if let Some(home) = dirs::home_dir() {
36 let _ = crate::rules_inject::inject_all_rules(&home);
37 }
38 crate::core::version_check::check_background();
39 });
40
41 let instructions = build_instructions_with_client(self.crp_mode, &name);
42 let capabilities = ServerCapabilities::builder().enable_tools().build();
43
44 Ok(InitializeResult::new(capabilities)
45 .with_server_info(Implementation::new("lean-ctx", "2.12.3"))
46 .with_instructions(instructions))
47 }
48
49 async fn list_tools(
50 &self,
51 _request: Option<PaginatedRequestParams>,
52 _context: RequestContext<RoleServer>,
53 ) -> Result<ListToolsResult, ErrorData> {
54 if should_use_unified(&self.client_name.read().await) {
55 return Ok(ListToolsResult {
56 tools: unified_tool_defs(),
57 ..Default::default()
58 });
59 }
60
61 Ok(ListToolsResult {
62 tools: vec![
63 tool_def(
64 "ctx_read",
65 "Read files with session caching and 7 compression modes. REPLACES native Read — using Read wastes tokens. \
66 Re-reads cost ~13 tokens. \
67 When no mode is specified, auto-selects the optimal mode based on file size, type, cache state, and task context. \
68 Modes: full (cached read), signatures (API surface), \
69 map (deps + exports — for context files you won't edit), \
70 diff (changed lines only), aggressive (syntax stripped), \
71 entropy (Shannon + Jaccard), task (task-relevant lines only via IB filter), \
72 reference (one-line metadata for irrelevant files). \
73 Lines: mode='lines:N-M' (e.g. 'lines:400-500'). \
74 Set fresh=true to bypass cache. Set start_line to read from a specific line.",
75 json!({
76 "type": "object",
77 "properties": {
78 "path": { "type": "string", "description": "Absolute file path to read" },
79 "mode": {
80 "type": "string",
81 "description": "Compression mode (default: full). Use 'map' for context-only files. For line ranges: 'lines:N-M' (e.g. 'lines:400-500')."
82 },
83 "start_line": {
84 "type": "integer",
85 "description": "Read from this line number to end of file. Bypasses cache stub — always returns actual content."
86 },
87 "fresh": {
88 "type": "boolean",
89 "description": "Bypass cache and force a full re-read. Use when running as a subagent that may not have the parent's context."
90 }
91 },
92 "required": ["path"]
93 }),
94 ),
95 tool_def(
96 "ctx_multi_read",
97 "REPLACES multiple Read calls — read many files in one MCP round-trip. \
98 Same modes as ctx_read (full, map, signatures, diff, aggressive, entropy). \
99 Results are joined with --- dividers; ends with aggregate summary (files read, tokens saved).",
100 json!({
101 "type": "object",
102 "properties": {
103 "paths": {
104 "type": "array",
105 "items": { "type": "string" },
106 "description": "Absolute file paths to read, in order"
107 },
108 "mode": {
109 "type": "string",
110 "enum": ["full", "signatures", "map", "diff", "aggressive", "entropy"],
111 "description": "Compression mode (default: full)"
112 }
113 },
114 "required": ["paths"]
115 }),
116 ),
117 tool_def(
118 "ctx_tree",
119 "List directory contents with file counts. REPLACES native ls/find — using ls wastes tokens. \
120 Token-efficient directory maps.",
121 json!({
122 "type": "object",
123 "properties": {
124 "path": { "type": "string", "description": "Directory path (default: .)" },
125 "depth": { "type": "integer", "description": "Max depth (default: 3)" },
126 "show_hidden": { "type": "boolean", "description": "Show hidden files" }
127 }
128 }),
129 ),
130 tool_def(
131 "ctx_shell",
132 "Run shell commands with output compression. REPLACES native Shell — using Shell wastes tokens. \
133 Pattern-based compression for git, npm, cargo, docker, tsc and 90+ commands.",
134 json!({
135 "type": "object",
136 "properties": {
137 "command": { "type": "string", "description": "Shell command to execute" }
138 },
139 "required": ["command"]
140 }),
141 ),
142 tool_def(
143 "ctx_search",
144 "Search code with regex patterns. REPLACES native Grep — using Grep wastes tokens. \
145 Respects .gitignore. Returns compact matching lines.",
146 json!({
147 "type": "object",
148 "properties": {
149 "pattern": { "type": "string", "description": "Regex pattern" },
150 "path": { "type": "string", "description": "Directory to search" },
151 "ext": { "type": "string", "description": "File extension filter" },
152 "max_results": { "type": "integer", "description": "Max results (default: 20)" },
153 "ignore_gitignore": { "type": "boolean", "description": "Set true to scan ALL files including .gitignore'd paths (default: false)" }
154 },
155 "required": ["pattern"]
156 }),
157 ),
158 tool_def(
159 "ctx_compress",
160 "Compress all cached files into an ultra-compact checkpoint. \
161 Use when conversations get long to create a memory snapshot.",
162 json!({
163 "type": "object",
164 "properties": {
165 "include_signatures": { "type": "boolean", "description": "Include signatures (default: true)" }
166 }
167 }),
168 ),
169 tool_def(
170 "ctx_benchmark",
171 "Benchmark compression strategies. action=file (default): single file. action=project: scan project directory with real token measurements, latency, and preservation scores.",
172 json!({
173 "type": "object",
174 "properties": {
175 "path": { "type": "string", "description": "File path (action=file) or project directory (action=project)" },
176 "action": { "type": "string", "description": "file (default) or project", "default": "file" },
177 "format": { "type": "string", "description": "Output format for project benchmark: terminal, markdown, json", "default": "terminal" }
178 },
179 "required": ["path"]
180 }),
181 ),
182 tool_def(
183 "ctx_metrics",
184 "Session statistics with tiktoken-measured token counts, cache hit rates, and per-tool savings.",
185 json!({
186 "type": "object",
187 "properties": {}
188 }),
189 ),
190 tool_def(
191 "ctx_analyze",
192 "Information-theoretic analysis using Shannon entropy and Jaccard similarity. \
193 Recommends the optimal compression mode for a file.",
194 json!({
195 "type": "object",
196 "properties": {
197 "path": { "type": "string", "description": "File path to analyze" }
198 },
199 "required": ["path"]
200 }),
201 ),
202 tool_def(
203 "ctx_cache",
204 "Manage the session cache. Actions: status (show cached files), \
205 clear (reset entire cache), invalidate (remove one file from cache). \
206 Use 'clear' when spawned as a subagent to start with a clean slate.",
207 json!({
208 "type": "object",
209 "properties": {
210 "action": {
211 "type": "string",
212 "enum": ["status", "clear", "invalidate"],
213 "description": "Cache operation to perform"
214 },
215 "path": {
216 "type": "string",
217 "description": "File path (required for 'invalidate' action)"
218 }
219 },
220 "required": ["action"]
221 }),
222 ),
223 tool_def(
224 "ctx_discover",
225 "Analyze shell history to find commands that could benefit from lean-ctx compression. \
226 Shows missed savings opportunities with estimated token/cost savings.",
227 json!({
228 "type": "object",
229 "properties": {
230 "limit": {
231 "type": "integer",
232 "description": "Max number of command types to show (default: 15)"
233 }
234 }
235 }),
236 ),
237 tool_def(
238 "ctx_smart_read",
239 "REPLACES built-in Read tool — auto-selects optimal compression mode based on \
240 file size, type, cache state, and token budget. Returns [auto:mode] prefix showing which mode was selected.",
241 json!({
242 "type": "object",
243 "properties": {
244 "path": { "type": "string", "description": "Absolute file path to read" }
245 },
246 "required": ["path"]
247 }),
248 ),
249 tool_def(
250 "ctx_delta",
251 "Incremental file update using Myers diff. Only sends changed lines (hunks with context) \
252 instead of full file content. Automatically updates the cache after computing the delta.",
253 json!({
254 "type": "object",
255 "properties": {
256 "path": { "type": "string", "description": "Absolute file path" }
257 },
258 "required": ["path"]
259 }),
260 ),
261 tool_def(
262 "ctx_dedup",
263 "Cross-file deduplication analysis and active dedup. Finds shared imports, boilerplate blocks, \
264 and repeated patterns across all cached files. Use action=apply to register shared blocks \
265 so subsequent ctx_read calls auto-replace duplicates with cross-file references.",
266 json!({
267 "type": "object",
268 "properties": {
269 "action": {
270 "type": "string",
271 "description": "analyze (default) or apply (register shared blocks for auto-dedup in ctx_read)",
272 "default": "analyze"
273 }
274 }
275 }),
276 ),
277 tool_def(
278 "ctx_fill",
279 "Priority-based context filling with a token budget. Given a list of files and a budget, \
280 automatically selects the best compression mode per file to maximize information within the budget. \
281 Higher-relevance files get more tokens (full mode); lower-relevance files get compressed (signatures).",
282 json!({
283 "type": "object",
284 "properties": {
285 "paths": {
286 "type": "array",
287 "items": { "type": "string" },
288 "description": "File paths to consider"
289 },
290 "budget": {
291 "type": "integer",
292 "description": "Maximum token budget to fill"
293 }
294 },
295 "required": ["paths", "budget"]
296 }),
297 ),
298 tool_def(
299 "ctx_intent",
300 "Semantic intent detection. Analyzes a natural language query to determine intent \
301 (fix bug, add feature, refactor, understand, test, config, deploy) and automatically \
302 selects and reads relevant files in the optimal compression mode.",
303 json!({
304 "type": "object",
305 "properties": {
306 "query": { "type": "string", "description": "Natural language description of the task" },
307 "project_root": { "type": "string", "description": "Project root directory (default: .)" }
308 },
309 "required": ["query"]
310 }),
311 ),
312 tool_def(
313 "ctx_response",
314 "Bi-directional response compression. Compresses LLM response text by removing filler \
315 content and applying TDD shortcuts. Use to verify compression quality of responses.",
316 json!({
317 "type": "object",
318 "properties": {
319 "text": { "type": "string", "description": "Response text to compress" }
320 },
321 "required": ["text"]
322 }),
323 ),
324 tool_def(
325 "ctx_context",
326 "Multi-turn context manager. Shows what files the LLM has already seen, \
327 which are cached, and provides a session overview to avoid redundant re-reads.",
328 json!({
329 "type": "object",
330 "properties": {}
331 }),
332 ),
333 tool_def(
334 "ctx_graph",
335 "Persistent project intelligence graph with incremental scanning. \
336 Actions: 'build' (scan & persist index), 'related' (BFS dependencies for a file), \
337 'symbol' (read single symbol via file.rs::fn_name), 'impact' (reverse deps, 2 levels), \
338 'status' (index age, file count, staleness).",
339 json!({
340 "type": "object",
341 "properties": {
342 "action": {
343 "type": "string",
344 "enum": ["build", "related", "symbol", "impact", "status"],
345 "description": "Graph operation: build, related, symbol, impact, status"
346 },
347 "path": {
348 "type": "string",
349 "description": "File path (related/impact) or file::symbol_name (symbol)"
350 },
351 "project_root": {
352 "type": "string",
353 "description": "Project root directory (default: .)"
354 }
355 },
356 "required": ["action"]
357 }),
358 ),
359 tool_def(
360 "ctx_session",
361 "Context Continuity Protocol (CCP) — session state manager for cross-chat continuity. \
362 Persists task context, findings, decisions, and file state across chat sessions \
363 and context compactions. Load a previous session to instantly restore context \
364 (~400 tokens vs ~50K cold start). LITM-aware: places critical info at attention-optimal positions. \
365 Actions: status, load, save, task, finding, decision, reset, list, cleanup.",
366 json!({
367 "type": "object",
368 "properties": {
369 "action": {
370 "type": "string",
371 "enum": ["status", "load", "save", "task", "finding", "decision", "reset", "list", "cleanup"],
372 "description": "Session operation to perform"
373 },
374 "value": {
375 "type": "string",
376 "description": "Value for task/finding/decision actions"
377 },
378 "session_id": {
379 "type": "string",
380 "description": "Session ID for load action (default: latest)"
381 }
382 },
383 "required": ["action"]
384 }),
385 ),
386 tool_def(
387 "ctx_knowledge",
388 "Persistent project knowledge store — remembers facts, patterns, and insights across sessions. \
389 Unlike session state (ephemeral), knowledge persists permanently per project. \
390 Use 'remember' to store facts the AI learns about the project (architecture, APIs, conventions). \
391 Use 'recall' to retrieve relevant knowledge. Use 'pattern' to record project patterns. \
392 Use 'consolidate' to extract findings/decisions from the current session into permanent knowledge. \
393 Use 'status' to see all stored knowledge. Use 'remove' to delete outdated facts. \
394 Actions: remember, recall, pattern, consolidate, status, remove, export.",
395 json!({
396 "type": "object",
397 "properties": {
398 "action": {
399 "type": "string",
400 "enum": ["remember", "recall", "pattern", "consolidate", "status", "remove", "export"],
401 "description": "Knowledge operation to perform"
402 },
403 "category": {
404 "type": "string",
405 "description": "Fact category (architecture, api, testing, deployment, conventions, dependencies)"
406 },
407 "key": {
408 "type": "string",
409 "description": "Fact key/identifier (e.g. 'auth-method', 'db-engine', 'test-framework')"
410 },
411 "value": {
412 "type": "string",
413 "description": "Fact value or pattern description"
414 },
415 "query": {
416 "type": "string",
417 "description": "Search query for recall action (matches against category, key, and value)"
418 },
419 "pattern_type": {
420 "type": "string",
421 "description": "Pattern type for pattern action (naming, structure, testing, error-handling)"
422 },
423 "examples": {
424 "type": "array",
425 "items": { "type": "string" },
426 "description": "Examples for pattern action"
427 },
428 "confidence": {
429 "type": "number",
430 "description": "Confidence score 0.0-1.0 for remember action (default: 0.8)"
431 }
432 },
433 "required": ["action"]
434 }),
435 ),
436 tool_def(
437 "ctx_agent",
438 "Multi-agent coordination — register agents, share messages, and coordinate work across \
439 parallel AI sessions (e.g. Cursor + Claude Code working simultaneously). \
440 Use 'register' at session start to identify this agent. \
441 Use 'list' to see other active agents. \
442 Use 'post' to share findings, warnings, or requests with other agents. \
443 Use 'read' to check for new messages from other agents. \
444 Use 'status' to update your current work status. \
445 Actions: register, list, post, read, status, info.",
446 json!({
447 "type": "object",
448 "properties": {
449 "action": {
450 "type": "string",
451 "enum": ["register", "list", "post", "read", "status", "info"],
452 "description": "Agent operation to perform"
453 },
454 "agent_type": {
455 "type": "string",
456 "description": "Agent type for register (cursor, claude, codex, gemini, subagent)"
457 },
458 "role": {
459 "type": "string",
460 "description": "Agent role (dev, review, test, plan)"
461 },
462 "message": {
463 "type": "string",
464 "description": "Message text for post action, or status detail for status action"
465 },
466 "category": {
467 "type": "string",
468 "description": "Message category for post (finding, warning, request, status)"
469 },
470 "to_agent": {
471 "type": "string",
472 "description": "Target agent ID for direct message (omit for broadcast)"
473 },
474 "status": {
475 "type": "string",
476 "enum": ["active", "idle", "finished"],
477 "description": "New status for status action"
478 }
479 },
480 "required": ["action"]
481 }),
482 ),
483 tool_def(
484 "ctx_overview",
485 "Multi-resolution project overview with task-conditioned relevance scoring. \
486 Shows all project files organized by relevance to the current task. \
487 Files are grouped into three levels: directly relevant (read full), \
488 context (read signatures), distant (reference only). \
489 Use this at session start to get a compact project map before diving into specific files.",
490 json!({
491 "type": "object",
492 "properties": {
493 "task": {
494 "type": "string",
495 "description": "Task description for relevance scoring (e.g. 'fix auth bug in login flow')"
496 },
497 "path": {
498 "type": "string",
499 "description": "Project root directory (default: .)"
500 }
501 }
502 }),
503 ),
504 tool_def(
505 "ctx_wrapped",
506 "Generate a LeanCTX savings report card. Shows tokens saved, cost avoided, \
507 top commands, cache efficiency. Periods: week, month, all.",
508 json!({
509 "type": "object",
510 "properties": {
511 "period": {
512 "type": "string",
513 "enum": ["week", "month", "all"],
514 "description": "Report period (default: week)"
515 }
516 }
517 }),
518 ),
519 tool_def(
520 "ctx_semantic_search",
521 "BM25 semantic code search across the project. Indexes code by symbols \
522 (functions, classes, structs) and searches by meaning. \
523 Use action='reindex' to rebuild the index.",
524 json!({
525 "type": "object",
526 "properties": {
527 "query": { "type": "string", "description": "Natural language search query" },
528 "path": { "type": "string", "description": "Project root to search (default: .)" },
529 "top_k": { "type": "integer", "description": "Number of results (default: 10)" },
530 "action": { "type": "string", "description": "reindex to rebuild index" }
531 },
532 "required": ["query"]
533 }),
534 ),
535 ],
536 ..Default::default()
537 })
538 }
539
540 async fn call_tool(
541 &self,
542 request: CallToolRequestParams,
543 _context: RequestContext<RoleServer>,
544 ) -> Result<CallToolResult, ErrorData> {
545 self.check_idle_expiry().await;
546
547 let original_name = request.name.as_ref().to_string();
548 let (resolved_name, resolved_args) = if original_name == "ctx" {
549 let sub = request
550 .arguments
551 .as_ref()
552 .and_then(|a| a.get("tool"))
553 .and_then(|v| v.as_str())
554 .map(|s| s.to_string())
555 .ok_or_else(|| {
556 ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
557 })?;
558 let tool_name = if sub.starts_with("ctx_") {
559 sub
560 } else {
561 format!("ctx_{sub}")
562 };
563 let mut args = request.arguments.unwrap_or_default();
564 args.remove("tool");
565 (tool_name, Some(args))
566 } else {
567 (original_name, request.arguments)
568 };
569 let name = resolved_name.as_str();
570 let args = &resolved_args;
571
572 let result_text = match name {
573 "ctx_read" => {
574 let path = get_str(args, "path")
575 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
576 let current_task = {
577 let session = self.session.read().await;
578 session.task.as_ref().map(|t| t.description.clone())
579 };
580 let task_ref = current_task.as_deref();
581 let mut mode = match get_str(args, "mode") {
582 Some(m) => m,
583 None => {
584 let cache = self.cache.read().await;
585 crate::tools::ctx_smart_read::select_mode_with_task(&cache, &path, task_ref)
586 }
587 };
588 let fresh = get_bool(args, "fresh").unwrap_or(false);
589 let start_line = get_int(args, "start_line");
590 if let Some(sl) = start_line {
591 let sl = sl.max(1_i64);
592 mode = format!("lines:{sl}-999999");
593 }
594 let stale = self.is_prompt_cache_stale().await;
595 let effective_mode = LeanCtxServer::upgrade_mode_if_stale(&mode, stale).to_string();
596 let mut cache = self.cache.write().await;
597 let output = if fresh {
598 crate::tools::ctx_read::handle_fresh_with_task(
599 &mut cache,
600 &path,
601 &effective_mode,
602 self.crp_mode,
603 task_ref,
604 )
605 } else {
606 crate::tools::ctx_read::handle_with_task(
607 &mut cache,
608 &path,
609 &effective_mode,
610 self.crp_mode,
611 task_ref,
612 )
613 };
614 let stale_note = if effective_mode != mode {
615 format!(
616 "⚡ Prompt cache expired (>60min idle) — auto-upgraded {mode} → {effective_mode} for better compression\n\n"
617 )
618 } else {
619 String::new()
620 };
621 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
622 let output_tokens = crate::core::tokens::count_tokens(&output);
623 let saved = original.saturating_sub(output_tokens);
624 let savings_note = if saved > 0 {
625 format!("\n[saved {saved} tokens vs native Read]")
626 } else {
627 String::new()
628 };
629 let output = format!("{stale_note}{output}{savings_note}");
630 let file_ref = cache.file_ref_map().get(&path).cloned();
631 let tokens = crate::core::tokens::count_tokens(&output);
632 drop(cache);
633 {
634 let mut session = self.session.write().await;
635 session.touch_file(&path, file_ref.as_deref(), &effective_mode, original);
636 if session.project_root.is_none() {
637 if let Some(root) = detect_project_root(&path) {
638 session.project_root = Some(root.clone());
639 let mut current = self.agent_id.write().await;
640 if current.is_none() {
641 let mut registry =
642 crate::core::agents::AgentRegistry::load_or_create();
643 registry.cleanup_stale(24);
644 let id = registry.register("mcp", None, &root);
645 let _ = registry.save();
646 *current = Some(id);
647 }
648 }
649 }
650 }
651 self.record_call(
652 "ctx_read",
653 original,
654 original.saturating_sub(tokens),
655 Some(mode.clone()),
656 )
657 .await;
658 {
659 let sig =
660 crate::core::mode_predictor::FileSignature::from_path(&path, original);
661 let density = if tokens > 0 {
662 original as f64 / tokens as f64
663 } else {
664 1.0
665 };
666 let outcome = crate::core::mode_predictor::ModeOutcome {
667 mode: mode.clone(),
668 tokens_in: original,
669 tokens_out: tokens,
670 density: density.min(1.0),
671 };
672 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
673 predictor.record(sig, outcome);
674 predictor.save();
675
676 let ext = std::path::Path::new(&path)
677 .extension()
678 .and_then(|e| e.to_str())
679 .unwrap_or("")
680 .to_string();
681 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(&path);
682 let cache = self.cache.read().await;
683 let stats = cache.get_stats();
684 let feedback_outcome = crate::core::feedback::CompressionOutcome {
685 session_id: format!("{}", std::process::id()),
686 language: ext,
687 entropy_threshold: thresholds.bpe_entropy,
688 jaccard_threshold: thresholds.jaccard,
689 total_turns: stats.total_reads as u32,
690 tokens_saved: original.saturating_sub(tokens) as u64,
691 tokens_original: original as u64,
692 cache_hits: stats.cache_hits as u32,
693 total_reads: stats.total_reads as u32,
694 task_completed: true,
695 timestamp: chrono::Local::now().to_rfc3339(),
696 };
697 drop(cache);
698 let mut store = crate::core::feedback::FeedbackStore::load();
699 store.record_outcome(feedback_outcome);
700 }
701 output
702 }
703 "ctx_multi_read" => {
704 let paths = get_str_array(args, "paths")
705 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
706 let mode = get_str(args, "mode").unwrap_or_else(|| "full".to_string());
707 let mut cache = self.cache.write().await;
708 let output =
709 crate::tools::ctx_multi_read::handle(&mut cache, &paths, &mode, self.crp_mode);
710 let mut total_original: usize = 0;
711 for path in &paths {
712 total_original = total_original
713 .saturating_add(cache.get(path).map(|e| e.original_tokens).unwrap_or(0));
714 }
715 let tokens = crate::core::tokens::count_tokens(&output);
716 drop(cache);
717 self.record_call(
718 "ctx_multi_read",
719 total_original,
720 total_original.saturating_sub(tokens),
721 Some(mode),
722 )
723 .await;
724 output
725 }
726 "ctx_tree" => {
727 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
728 let depth = get_int(args, "depth").unwrap_or(3) as usize;
729 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
730 let (result, original) = crate::tools::ctx_tree::handle(&path, depth, show_hidden);
731 let sent = crate::core::tokens::count_tokens(&result);
732 let saved = original.saturating_sub(sent);
733 self.record_call("ctx_tree", original, saved, None).await;
734 let savings_note = if saved > 0 {
735 format!("\n[saved {saved} tokens vs native ls]")
736 } else {
737 String::new()
738 };
739 format!("{result}{savings_note}")
740 }
741 "ctx_shell" => {
742 let command = get_str(args, "command")
743 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
744 let output = execute_command(&command);
745 let result = crate::tools::ctx_shell::handle(&command, &output, self.crp_mode);
746 let original = crate::core::tokens::count_tokens(&output);
747 let sent = crate::core::tokens::count_tokens(&result);
748 let saved = original.saturating_sub(sent);
749 self.record_call("ctx_shell", original, saved, None).await;
750 let savings_note = if saved > 0 {
751 format!("\n[saved {saved} tokens vs native Shell]")
752 } else {
753 String::new()
754 };
755 format!("{result}{savings_note}")
756 }
757 "ctx_search" => {
758 let pattern = get_str(args, "pattern")
759 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
760 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
761 let ext = get_str(args, "ext");
762 let max = get_int(args, "max_results").unwrap_or(20) as usize;
763 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
764 let (result, original) = crate::tools::ctx_search::handle(
765 &pattern,
766 &path,
767 ext.as_deref(),
768 max,
769 self.crp_mode,
770 !no_gitignore,
771 );
772 let sent = crate::core::tokens::count_tokens(&result);
773 let saved = original.saturating_sub(sent);
774 self.record_call("ctx_search", original, saved, None).await;
775 let savings_note = if saved > 0 {
776 format!("\n[saved {saved} tokens vs native Grep]")
777 } else {
778 String::new()
779 };
780 format!("{result}{savings_note}")
781 }
782 "ctx_compress" => {
783 let include_sigs = get_bool(args, "include_signatures").unwrap_or(true);
784 let cache = self.cache.read().await;
785 let result =
786 crate::tools::ctx_compress::handle(&cache, include_sigs, self.crp_mode);
787 drop(cache);
788 self.record_call("ctx_compress", 0, 0, None).await;
789 result
790 }
791 "ctx_benchmark" => {
792 let path = get_str(args, "path")
793 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
794 let action = get_str(args, "action").unwrap_or_default();
795 let result = if action == "project" {
796 let fmt = get_str(args, "format").unwrap_or_default();
797 let bench = crate::core::benchmark::run_project_benchmark(&path);
798 match fmt.as_str() {
799 "json" => crate::core::benchmark::format_json(&bench),
800 "markdown" | "md" => crate::core::benchmark::format_markdown(&bench),
801 _ => crate::core::benchmark::format_terminal(&bench),
802 }
803 } else {
804 crate::tools::ctx_benchmark::handle(&path, self.crp_mode)
805 };
806 self.record_call("ctx_benchmark", 0, 0, None).await;
807 result
808 }
809 "ctx_metrics" => {
810 let cache = self.cache.read().await;
811 let calls = self.tool_calls.read().await;
812 let result = crate::tools::ctx_metrics::handle(&cache, &calls, self.crp_mode);
813 drop(cache);
814 drop(calls);
815 self.record_call("ctx_metrics", 0, 0, None).await;
816 result
817 }
818 "ctx_analyze" => {
819 let path = get_str(args, "path")
820 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
821 let result = crate::tools::ctx_analyze::handle(&path, self.crp_mode);
822 self.record_call("ctx_analyze", 0, 0, None).await;
823 result
824 }
825 "ctx_discover" => {
826 let limit = get_int(args, "limit").unwrap_or(15) as usize;
827 let history = crate::cli::load_shell_history_pub();
828 let result = crate::tools::ctx_discover::discover_from_history(&history, limit);
829 self.record_call("ctx_discover", 0, 0, None).await;
830 result
831 }
832 "ctx_smart_read" => {
833 let path = get_str(args, "path")
834 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
835 let mut cache = self.cache.write().await;
836 let output = crate::tools::ctx_smart_read::handle(&mut cache, &path, self.crp_mode);
837 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
838 let tokens = crate::core::tokens::count_tokens(&output);
839 drop(cache);
840 self.record_call(
841 "ctx_smart_read",
842 original,
843 original.saturating_sub(tokens),
844 Some("auto".to_string()),
845 )
846 .await;
847 output
848 }
849 "ctx_delta" => {
850 let path = get_str(args, "path")
851 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
852 let mut cache = self.cache.write().await;
853 let output = crate::tools::ctx_delta::handle(&mut cache, &path);
854 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
855 let tokens = crate::core::tokens::count_tokens(&output);
856 drop(cache);
857 {
858 let mut session = self.session.write().await;
859 session.mark_modified(&path);
860 }
861 self.record_call(
862 "ctx_delta",
863 original,
864 original.saturating_sub(tokens),
865 Some("delta".to_string()),
866 )
867 .await;
868 output
869 }
870 "ctx_dedup" => {
871 let action = get_str(args, "action").unwrap_or_default();
872 if action == "apply" {
873 let mut cache = self.cache.write().await;
874 let result = crate::tools::ctx_dedup::handle_action(&mut cache, &action);
875 drop(cache);
876 self.record_call("ctx_dedup", 0, 0, None).await;
877 result
878 } else {
879 let cache = self.cache.read().await;
880 let result = crate::tools::ctx_dedup::handle(&cache);
881 drop(cache);
882 self.record_call("ctx_dedup", 0, 0, None).await;
883 result
884 }
885 }
886 "ctx_fill" => {
887 let paths = get_str_array(args, "paths")
888 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
889 let budget = get_int(args, "budget")
890 .ok_or_else(|| ErrorData::invalid_params("budget is required", None))?
891 as usize;
892 let mut cache = self.cache.write().await;
893 let output =
894 crate::tools::ctx_fill::handle(&mut cache, &paths, budget, self.crp_mode);
895 drop(cache);
896 self.record_call("ctx_fill", 0, 0, Some(format!("budget:{budget}")))
897 .await;
898 output
899 }
900 "ctx_intent" => {
901 let query = get_str(args, "query")
902 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
903 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
904 let mut cache = self.cache.write().await;
905 let output =
906 crate::tools::ctx_intent::handle(&mut cache, &query, &root, self.crp_mode);
907 drop(cache);
908 {
909 let mut session = self.session.write().await;
910 session.set_task(&query, Some("intent"));
911 }
912 self.record_call("ctx_intent", 0, 0, Some("semantic".to_string()))
913 .await;
914 output
915 }
916 "ctx_response" => {
917 let text = get_str(args, "text")
918 .ok_or_else(|| ErrorData::invalid_params("text is required", None))?;
919 let output = crate::tools::ctx_response::handle(&text, self.crp_mode);
920 self.record_call("ctx_response", 0, 0, None).await;
921 output
922 }
923 "ctx_context" => {
924 let cache = self.cache.read().await;
925 let turn = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
926 let result = crate::tools::ctx_context::handle_status(&cache, turn, self.crp_mode);
927 drop(cache);
928 self.record_call("ctx_context", 0, 0, None).await;
929 result
930 }
931 "ctx_graph" => {
932 let action = get_str(args, "action")
933 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
934 let path = get_str(args, "path");
935 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
936 let mut cache = self.cache.write().await;
937 let result = crate::tools::ctx_graph::handle(
938 &action,
939 path.as_deref(),
940 &root,
941 &mut cache,
942 self.crp_mode,
943 );
944 drop(cache);
945 self.record_call("ctx_graph", 0, 0, Some(action)).await;
946 result
947 }
948 "ctx_cache" => {
949 let action = get_str(args, "action")
950 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
951 let mut cache = self.cache.write().await;
952 let result = match action.as_str() {
953 "status" => {
954 let entries = cache.get_all_entries();
955 if entries.is_empty() {
956 "Cache empty — no files tracked.".to_string()
957 } else {
958 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
959 for (path, entry) in &entries {
960 let fref = cache
961 .file_ref_map()
962 .get(*path)
963 .map(|s| s.as_str())
964 .unwrap_or("F?");
965 lines.push(format!(
966 " {fref}={} [{}L, {}t, read {}x]",
967 crate::core::protocol::shorten_path(path),
968 entry.line_count,
969 entry.original_tokens,
970 entry.read_count
971 ));
972 }
973 lines.join("\n")
974 }
975 }
976 "clear" => {
977 let count = cache.clear();
978 format!("Cache cleared — {count} file(s) removed. Next ctx_read will return full content.")
979 }
980 "invalidate" => {
981 let path = get_str(args, "path").ok_or_else(|| {
982 ErrorData::invalid_params("path is required for invalidate", None)
983 })?;
984 if cache.invalidate(&path) {
985 format!(
986 "Invalidated cache for {}. Next ctx_read will return full content.",
987 crate::core::protocol::shorten_path(&path)
988 )
989 } else {
990 format!(
991 "{} was not in cache.",
992 crate::core::protocol::shorten_path(&path)
993 )
994 }
995 }
996 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
997 };
998 drop(cache);
999 self.record_call("ctx_cache", 0, 0, Some(action)).await;
1000 result
1001 }
1002 "ctx_session" => {
1003 let action = get_str(args, "action")
1004 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1005 let value = get_str(args, "value");
1006 let sid = get_str(args, "session_id");
1007 let mut session = self.session.write().await;
1008 let result = crate::tools::ctx_session::handle(
1009 &mut session,
1010 &action,
1011 value.as_deref(),
1012 sid.as_deref(),
1013 );
1014 drop(session);
1015 self.record_call("ctx_session", 0, 0, Some(action)).await;
1016 result
1017 }
1018 "ctx_knowledge" => {
1019 let action = get_str(args, "action")
1020 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1021 let category = get_str(args, "category");
1022 let key = get_str(args, "key");
1023 let value = get_str(args, "value");
1024 let query = get_str(args, "query");
1025 let pattern_type = get_str(args, "pattern_type");
1026 let examples = get_str_array(args, "examples");
1027 let confidence: Option<f32> = args
1028 .as_ref()
1029 .and_then(|a| a.get("confidence"))
1030 .and_then(|v| v.as_f64())
1031 .map(|v| v as f32);
1032
1033 let session = self.session.read().await;
1034 let session_id = session.id.clone();
1035 let project_root = session.project_root.clone().unwrap_or_else(|| {
1036 std::env::current_dir()
1037 .map(|p| p.to_string_lossy().to_string())
1038 .unwrap_or_else(|_| "unknown".to_string())
1039 });
1040 drop(session);
1041
1042 let result = crate::tools::ctx_knowledge::handle(
1043 &project_root,
1044 &action,
1045 category.as_deref(),
1046 key.as_deref(),
1047 value.as_deref(),
1048 query.as_deref(),
1049 &session_id,
1050 pattern_type.as_deref(),
1051 examples,
1052 confidence,
1053 );
1054 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
1055 result
1056 }
1057 "ctx_agent" => {
1058 let action = get_str(args, "action")
1059 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1060 let agent_type = get_str(args, "agent_type");
1061 let role = get_str(args, "role");
1062 let message = get_str(args, "message");
1063 let category = get_str(args, "category");
1064 let to_agent = get_str(args, "to_agent");
1065 let status = get_str(args, "status");
1066
1067 let session = self.session.read().await;
1068 let project_root = session.project_root.clone().unwrap_or_else(|| {
1069 std::env::current_dir()
1070 .map(|p| p.to_string_lossy().to_string())
1071 .unwrap_or_else(|_| "unknown".to_string())
1072 });
1073 drop(session);
1074
1075 let current_agent_id = self.agent_id.read().await.clone();
1076 let result = crate::tools::ctx_agent::handle(
1077 &action,
1078 agent_type.as_deref(),
1079 role.as_deref(),
1080 &project_root,
1081 current_agent_id.as_deref(),
1082 message.as_deref(),
1083 category.as_deref(),
1084 to_agent.as_deref(),
1085 status.as_deref(),
1086 );
1087
1088 if action == "register" {
1089 if let Some(id) = result.split(':').nth(1) {
1090 let id = id.split_whitespace().next().unwrap_or("").to_string();
1091 if !id.is_empty() {
1092 *self.agent_id.write().await = Some(id);
1093 }
1094 }
1095 }
1096
1097 self.record_call("ctx_agent", 0, 0, Some(action)).await;
1098 result
1099 }
1100 "ctx_overview" => {
1101 let task = get_str(args, "task");
1102 let path = get_str(args, "path");
1103 let cache = self.cache.read().await;
1104 let result = crate::tools::ctx_overview::handle(
1105 &cache,
1106 task.as_deref(),
1107 path.as_deref(),
1108 self.crp_mode,
1109 );
1110 drop(cache);
1111 self.record_call("ctx_overview", 0, 0, Some("overview".to_string()))
1112 .await;
1113 result
1114 }
1115 "ctx_wrapped" => {
1116 let period = get_str(args, "period").unwrap_or_else(|| "week".to_string());
1117 let result = crate::tools::ctx_wrapped::handle(&period);
1118 self.record_call("ctx_wrapped", 0, 0, Some(period)).await;
1119 result
1120 }
1121 "ctx_semantic_search" => {
1122 let query = get_str(args, "query")
1123 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
1124 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
1125 let top_k = get_int(args, "top_k").unwrap_or(10) as usize;
1126 let action = get_str(args, "action").unwrap_or_default();
1127 let result = if action == "reindex" {
1128 crate::tools::ctx_semantic_search::handle_reindex(&path)
1129 } else {
1130 crate::tools::ctx_semantic_search::handle(&query, &path, top_k, self.crp_mode)
1131 };
1132 self.record_call("ctx_semantic_search", 0, 0, Some("semantic".to_string()))
1133 .await;
1134 result
1135 }
1136 _ => {
1137 return Err(ErrorData::invalid_params(
1138 format!("Unknown tool: {name}"),
1139 None,
1140 ));
1141 }
1142 };
1143
1144 let skip_checkpoint = matches!(
1145 name,
1146 "ctx_compress"
1147 | "ctx_metrics"
1148 | "ctx_benchmark"
1149 | "ctx_analyze"
1150 | "ctx_cache"
1151 | "ctx_discover"
1152 | "ctx_dedup"
1153 | "ctx_session"
1154 | "ctx_knowledge"
1155 | "ctx_agent"
1156 | "ctx_wrapped"
1157 | "ctx_overview"
1158 );
1159
1160 if !skip_checkpoint && self.increment_and_check() {
1161 if let Some(checkpoint) = self.auto_checkpoint().await {
1162 let combined = format!(
1163 "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
1164 self.checkpoint_interval
1165 );
1166 return Ok(CallToolResult::success(vec![Content::text(combined)]));
1167 }
1168 }
1169
1170 let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1171 if current_count > 0 && current_count.is_multiple_of(100) {
1172 std::thread::spawn(cloud_background_tasks);
1173 }
1174
1175 Ok(CallToolResult::success(vec![Content::text(result_text)]))
1176 }
1177}
1178
1179fn build_instructions(crp_mode: CrpMode) -> String {
1180 build_instructions_with_client(crp_mode, "")
1181}
1182
1183fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
1184 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
1185 let session_block = match crate::core::session::SessionState::load_latest() {
1186 Some(ref session) => {
1187 let positioned = crate::core::litm::position_optimize(session);
1188 format!(
1189 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
1190 profile.name, positioned.begin_block
1191 )
1192 }
1193 None => String::new(),
1194 };
1195
1196 let knowledge_block = {
1197 let project_root = crate::core::session::SessionState::load_latest()
1198 .and_then(|s| s.project_root)
1199 .or_else(|| {
1200 std::env::current_dir()
1201 .ok()
1202 .map(|p| p.to_string_lossy().to_string())
1203 });
1204 match project_root {
1205 Some(root) => {
1206 let knowledge = crate::core::knowledge::ProjectKnowledge::load(&root);
1207 match knowledge {
1208 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
1209 format!("\n--- PROJECT KNOWLEDGE ---\n{}\n---\n", k.format_summary())
1210 }
1211 _ => String::new(),
1212 }
1213 }
1214 None => String::new(),
1215 }
1216 };
1217
1218 let mut base = format!("\
1221CRITICAL: NEVER use native Read, Shell, Grep, or ls tools. ALWAYS use ctx_read, ctx_shell, ctx_search, ctx_tree from this MCP server instead.\n\
1222\n\
1223lean-ctx MCP — tool replacement for reading, running commands, and searching.\n\
1224\n\
1225REPLACE these built-in tools with lean-ctx equivalents:\n\
1226• Read file → ctx_read(path, mode) — NEVER use Read tool\n\
1227• Run command → ctx_shell(command) — NEVER use Shell tool\n\
1228• Search code → ctx_search(pattern, path) — NEVER use Grep tool\n\
1229• List files → ctx_tree(path, depth) — NEVER use Shell with ls/find\n\
1230\n\
1231KEEP using these built-in tools normally (lean-ctx has NO replacement for them):\n\
1232• Write — create/overwrite files directly\n\
1233• StrReplace — edit files directly\n\
1234• Delete — delete files directly\n\
1235• Glob — find files by pattern\n\
1236You do NOT need to ctx_read a file before creating it with Write.\n\
1237\n\
1238ctx_read modes: full (cached, for files you edit), map (deps+API, context-only), \
1239signatures, diff, task (IB-filtered task-relevant lines), reference (one-line metadata), \
1240aggressive, entropy, lines:N-M (specific line ranges). \
1241Auto-selects optimal mode when none specified. Re-reads cost ~13 tokens. File refs F1,F2.. persist.\n\
1242IMPORTANT: If ctx_read returns 'cached Nt NL' and you need the actual file content, you MUST either:\n\
1243 1. Set fresh=true to force a full re-read, OR\n\
1244 2. Use start_line=N to read from a specific line, OR\n\
1245 3. Use mode='lines:N-M' to read a specific range.\n\
1246Do not fall back to native Read tools — always use fresh=true or start_line instead.\n\
1247\n\
1248PROACTIVE (use without being asked):\n\
1249• ctx_overview(task) — at session start, get task-relevant project map\n\
1250• ctx_compress — when context grows large, create checkpoint\n\
1251• ctx_session load — restore previous session on new chat\n\
1252\n\
1253ADDITIONAL TOOLS (see tool descriptions for parameters):\n\
1254• ctx_session — cross-session memory (load/save/status/task/finding/decision)\n\
1255• ctx_knowledge — persistent project facts (remember/recall/pattern/status/remove/consolidate)\n\
1256• ctx_agent — multi-agent coordination (register/list/post/read/status)\n\
1257• ctx_metrics — token savings stats\n\
1258• ctx_analyze/ctx_benchmark — compression analysis per file\n\
1259• ctx_cache — manage file cache (status/clear/invalidate)\n\
1260• ctx_wrapped — savings report card\n\
1261• ctx_compress — context checkpoint\n\
1262\n\
1263Auto-checkpoint runs every 15 tool calls. Cache auto-clears after 5 min idle.\n\
1264\n\
1265COMMUNICATION PROTOCOL (CEP v1):\n\
12661. ACT FIRST — Execute tool calls immediately, summarize after.\n\
12672. DELTA ONLY — Reference cached files by Fn ID, never repeat known context.\n\
12683. STRUCTURED OVER PROSE — Use notation: +line / -line / ~line, tool(args) → result.\n\
12694. ONE LINE PER ACTION — Summarize, don't explain.\n\
12705. QUALITY ANCHOR — Never skip edge case analysis to save tokens.\n\
1271\n\
1272{decoder_block}\n\
1273\n\
1274REMINDER: NEVER use native Read, Shell, Grep, or ls. ALWAYS use ctx_read, ctx_shell, ctx_search, ctx_tree. Every single time.\n\
1275\n\
1276{session_block}\
1277{knowledge_block}",
1278 decoder_block = crate::core::protocol::instruction_decoder_block()
1279 );
1280
1281 if should_use_unified(client_name) {
1282 base.push_str(
1283 "\n\n\
1284UNIFIED TOOL MODE (active):\n\
1285Additional tools are accessed via ctx() meta-tool: ctx(tool=\"<name>\", ...params).\n\
1286See the ctx() tool description for available sub-tools.\n",
1287 );
1288 }
1289
1290 let base = base;
1291 match crp_mode {
1292 CrpMode::Off => base,
1293 CrpMode::Compact => {
1294 format!(
1295 "{base}\n\n\
1296 CRP MODE: compact\n\
1297 Respond using Compact Response Protocol:\n\
1298 • Omit filler words, articles, and redundant phrases\n\
1299 • Use symbol shorthand: → ∴ ≈ ✓ ✗\n\
1300 • Abbreviate: fn, cfg, impl, deps, req, res, ctx, err, ok, ret, arg, val, ty, mod\n\
1301 • Use compact lists instead of prose\n\
1302 • Prefer code blocks over natural language explanations\n\
1303 • For code changes: show only diff lines (+/-), not full files"
1304 )
1305 }
1306 CrpMode::Tdd => {
1307 format!(
1308 "{base}\n\n\
1309 CRP MODE: tdd (Token Dense Dialect)\n\
1310 CRITICAL: Maximize information density. Every token must carry meaning.\n\
1311 \n\
1312 RESPONSE RULES:\n\
1313 • Drop all articles (a, the, an), filler words, and pleasantries\n\
1314 • Reference files by Fn refs only, never full paths\n\
1315 • For code changes: show only diff lines, not full files\n\
1316 • No explanations unless asked — just show the solution\n\
1317 • Use tabular format for structured data\n\
1318 • Abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ok, ret, arg, val, ty, mod\n\
1319 \n\
1320 SYMBOLS (each = 1 token, replaces 5-10 tokens of prose):\n\
1321 Structural: λ=function §=module/struct ∂=interface/trait τ=type ε=enum\n\
1322 Actions: ⊕=add ⊖=remove ∆=modify →=returns ⇒=implies\n\
1323 Status: ✓=ok ✗=fail ⚠=warning\n\
1324 \n\
1325 CHANGE NOTATION (use for all code modifications):\n\
1326 ⊕F1:42 param(timeout:Duration) — added parameter\n\
1327 ⊖F1:10-15 — removed lines\n\
1328 ∆F1:42 validate_token → verify_jwt — renamed/refactored\n\
1329 \n\
1330 STATUS NOTATION:\n\
1331 ctx_read(F1) → 808L cached ✓\n\
1332 cargo test → 82 passed ✓ 0 failed\n\
1333 \n\
1334 SYMBOL TABLE: Tool outputs include a §MAP section mapping long identifiers to short IDs.\n\
1335 Use these short IDs in all subsequent references."
1336 )
1337 }
1338 }
1339}
1340
1341fn tool_def(name: &'static str, description: &'static str, schema_value: Value) -> Tool {
1342 let schema: Map<String, Value> = match schema_value {
1343 Value::Object(map) => map,
1344 _ => Map::new(),
1345 };
1346 Tool::new(name, description, Arc::new(schema))
1347}
1348
1349fn unified_tool_defs() -> Vec<Tool> {
1350 vec![
1351 tool_def(
1352 "ctx_read",
1353 "Read files with caching + 8 compression modes. REPLACES native Read. \
1354 Auto-selects optimal mode when none specified. Re-reads ~13 tok. \
1355 Modes: full, map, signatures, diff, aggressive, entropy, task, reference, lines:N-M. \
1356 fresh=true bypasses cache.",
1357 json!({
1358 "type": "object",
1359 "properties": {
1360 "path": { "type": "string", "description": "File path" },
1361 "mode": { "type": "string" },
1362 "start_line": { "type": "integer" },
1363 "fresh": { "type": "boolean" }
1364 },
1365 "required": ["path"]
1366 }),
1367 ),
1368 tool_def(
1369 "ctx_shell",
1370 "Run shell commands with output compression. REPLACES native Shell.",
1371 json!({
1372 "type": "object",
1373 "properties": {
1374 "command": { "type": "string", "description": "Shell command" }
1375 },
1376 "required": ["command"]
1377 }),
1378 ),
1379 tool_def(
1380 "ctx_search",
1381 "Search code with regex patterns. REPLACES native Grep. Respects .gitignore.",
1382 json!({
1383 "type": "object",
1384 "properties": {
1385 "pattern": { "type": "string", "description": "Regex pattern" },
1386 "path": { "type": "string" },
1387 "ext": { "type": "string" },
1388 "max_results": { "type": "integer" },
1389 "ignore_gitignore": { "type": "boolean" }
1390 },
1391 "required": ["pattern"]
1392 }),
1393 ),
1394 tool_def(
1395 "ctx_tree",
1396 "List directory contents with file counts. REPLACES native ls/find.",
1397 json!({
1398 "type": "object",
1399 "properties": {
1400 "path": { "type": "string" },
1401 "depth": { "type": "integer" },
1402 "show_hidden": { "type": "boolean" }
1403 }
1404 }),
1405 ),
1406 tool_def(
1407 "ctx",
1408 "Lean-ctx meta-tool — 21 sub-tools via single endpoint. Set 'tool' param + sibling fields.\n\
1409 Sub-tools:\n\
1410 • compress — create context checkpoint\n\
1411 • metrics — show token savings stats\n\
1412 • analyze(path) — optimal compression mode for file\n\
1413 • cache(action=status|clear|invalidate) — manage file cache\n\
1414 • discover — find missed compression opportunities\n\
1415 • smart_read(path) — auto-select best read mode\n\
1416 • delta(path) — show only changed lines since last read\n\
1417 • dedup(paths) — deduplicate across files\n\
1418 • fill(path) — suggest next likely edit location\n\
1419 • intent(text) — classify user intent for routing\n\
1420 • response(text) — compress LLM output, remove filler\n\
1421 • context(budget) — budget-aware context assembly\n\
1422 • graph(action=build|query|impact) — code dependency graph\n\
1423 • session(action=load|save|status|task|finding|decision) — cross-session memory\n\
1424 • knowledge(action=remember|recall|pattern|status|remove|consolidate) — persistent project memory\n\
1425 • agent(action=register|list|post|read|status) — multi-agent coordination\n\
1426 • overview(task) — task-relevant project map\n\
1427 • wrapped(period) — savings report card\n\
1428 • benchmark(path) — token counts per compression mode\n\
1429 • multi_read(paths) — batch file read\n\
1430 • semantic_search(query, path?, limit?) — BM25 code search",
1431 json!({
1432 "type": "object",
1433 "properties": {
1434 "tool": {
1435 "type": "string",
1436 "description": "compress|metrics|analyze|cache|discover|smart_read|delta|dedup|fill|intent|response|context|graph|session|knowledge|agent|overview|wrapped|benchmark|multi_read|semantic_search"
1437 },
1438 "action": { "type": "string" },
1439 "path": { "type": "string" },
1440 "paths": { "type": "array", "items": { "type": "string" } },
1441 "query": { "type": "string" },
1442 "value": { "type": "string" },
1443 "category": { "type": "string" },
1444 "key": { "type": "string" },
1445 "budget": { "type": "integer" },
1446 "task": { "type": "string" },
1447 "mode": { "type": "string" },
1448 "text": { "type": "string" },
1449 "message": { "type": "string" },
1450 "session_id": { "type": "string" },
1451 "period": { "type": "string" },
1452 "format": { "type": "string" },
1453 "agent_type": { "type": "string" },
1454 "role": { "type": "string" },
1455 "status": { "type": "string" },
1456 "pattern_type": { "type": "string" },
1457 "examples": { "type": "array", "items": { "type": "string" } },
1458 "confidence": { "type": "number" },
1459 "project_root": { "type": "string" },
1460 "include_signatures": { "type": "boolean" },
1461 "limit": { "type": "integer" },
1462 "to_agent": { "type": "string" },
1463 "show_hidden": { "type": "boolean" }
1464 },
1465 "required": ["tool"]
1466 }),
1467 ),
1468 ]
1469}
1470
1471fn should_use_unified(client_name: &str) -> bool {
1472 if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
1473 return false;
1474 }
1475 if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
1476 return true;
1477 }
1478 let _ = client_name;
1479 false
1480}
1481
1482fn get_str_array(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<Vec<String>> {
1483 let arr = args.as_ref()?.get(key)?.as_array()?;
1484 let mut out = Vec::with_capacity(arr.len());
1485 for v in arr {
1486 let s = v.as_str()?.to_string();
1487 out.push(s);
1488 }
1489 Some(out)
1490}
1491
1492fn get_str(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<String> {
1493 args.as_ref()?.get(key)?.as_str().map(|s| s.to_string())
1494}
1495
1496fn get_int(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<i64> {
1497 args.as_ref()?.get(key)?.as_i64()
1498}
1499
1500fn get_bool(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<bool> {
1501 args.as_ref()?.get(key)?.as_bool()
1502}
1503
1504fn execute_command(command: &str) -> String {
1505 let (shell, flag) = crate::shell::shell_and_flag();
1506 let output = std::process::Command::new(&shell)
1507 .arg(&flag)
1508 .arg(command)
1509 .env("LEAN_CTX_ACTIVE", "1")
1510 .output();
1511
1512 match output {
1513 Ok(out) => {
1514 let stdout = String::from_utf8_lossy(&out.stdout);
1515 let stderr = String::from_utf8_lossy(&out.stderr);
1516 if stdout.is_empty() {
1517 stderr.to_string()
1518 } else if stderr.is_empty() {
1519 stdout.to_string()
1520 } else {
1521 format!("{stdout}\n{stderr}")
1522 }
1523 }
1524 Err(e) => format!("ERROR: {e}"),
1525 }
1526}
1527
1528fn detect_project_root(file_path: &str) -> Option<String> {
1529 let mut dir = std::path::Path::new(file_path).parent()?;
1530 loop {
1531 if dir.join(".git").exists() {
1532 return Some(dir.to_string_lossy().to_string());
1533 }
1534 dir = dir.parent()?;
1535 }
1536}
1537
1538fn cloud_background_tasks() {
1539 use crate::core::config::Config;
1540
1541 let mut config = Config::load();
1542 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1543
1544 let already_contributed = config
1545 .cloud
1546 .last_contribute
1547 .as_deref()
1548 .map(|d| d == today)
1549 .unwrap_or(false);
1550 let already_synced = config
1551 .cloud
1552 .last_sync
1553 .as_deref()
1554 .map(|d| d == today)
1555 .unwrap_or(false);
1556 let already_pulled = config
1557 .cloud
1558 .last_model_pull
1559 .as_deref()
1560 .map(|d| d == today)
1561 .unwrap_or(false);
1562
1563 if config.cloud.contribute_enabled && !already_contributed {
1564 if let Some(home) = dirs::home_dir() {
1565 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
1566 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
1567 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
1568 let mut entries = Vec::new();
1569 if let Some(history) = predictor["history"].as_object() {
1570 for (_key, outcomes) in history {
1571 if let Some(arr) = outcomes.as_array() {
1572 for outcome in arr.iter().rev().take(3) {
1573 let ext = outcome["ext"].as_str().unwrap_or("unknown");
1574 let mode = outcome["mode"].as_str().unwrap_or("full");
1575 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
1576 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
1577 let ratio = if t_in > 0 {
1578 1.0 - t_out as f64 / t_in as f64
1579 } else {
1580 0.0
1581 };
1582 let bucket = match t_in {
1583 0..=500 => "0-500",
1584 501..=2000 => "500-2k",
1585 2001..=10000 => "2k-10k",
1586 _ => "10k+",
1587 };
1588 entries.push(serde_json::json!({
1589 "file_ext": format!(".{ext}"),
1590 "size_bucket": bucket,
1591 "best_mode": mode,
1592 "compression_ratio": (ratio * 100.0).round() / 100.0,
1593 }));
1594 if entries.len() >= 200 {
1595 break;
1596 }
1597 }
1598 }
1599 if entries.len() >= 200 {
1600 break;
1601 }
1602 }
1603 }
1604 if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
1605 config.cloud.last_contribute = Some(today.clone());
1606 }
1607 }
1608 }
1609 }
1610 }
1611
1612 if crate::cloud_client::check_pro() {
1613 if !already_synced {
1614 let stats_data = crate::core::stats::format_gain_json();
1615 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
1616 let entry = serde_json::json!({
1617 "date": &today,
1618 "tokens_original": parsed["total_original_tokens"].as_i64().unwrap_or(0),
1619 "tokens_compressed": parsed["total_compressed_tokens"].as_i64().unwrap_or(0),
1620 "tokens_saved": parsed["total_saved_tokens"].as_i64().unwrap_or(0),
1621 "tool_calls": parsed["total_calls"].as_i64().unwrap_or(0),
1622 "cache_hits": parsed["cache_hits"].as_i64().unwrap_or(0),
1623 "cache_misses": parsed["cache_misses"].as_i64().unwrap_or(0),
1624 });
1625 if crate::cloud_client::sync_stats(&[entry]).is_ok() {
1626 config.cloud.last_sync = Some(today.clone());
1627 }
1628 }
1629 }
1630
1631 if !already_pulled {
1632 if let Ok(data) = crate::cloud_client::pull_pro_models() {
1633 let _ = crate::cloud_client::save_pro_models(&data);
1634 config.cloud.last_model_pull = Some(today.clone());
1635 }
1636 }
1637 }
1638
1639 let _ = config.save();
1640}
1641
1642#[cfg(test)]
1643mod tests {
1644 use super::*;
1645
1646 #[test]
1647 fn test_should_use_unified_defaults_to_false() {
1648 assert!(!should_use_unified("cursor"));
1649 assert!(!should_use_unified("claude-code"));
1650 assert!(!should_use_unified("windsurf"));
1651 assert!(!should_use_unified(""));
1652 assert!(!should_use_unified("some-unknown-client"));
1653 }
1654
1655 #[test]
1656 fn test_unified_tool_count() {
1657 let tools = unified_tool_defs();
1658 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1659 }
1660}