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.16.6"))
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::hooks::refresh_installed_hooks();
39 crate::core::version_check::check_background();
40 });
41
42 let instructions = build_instructions_with_client(self.crp_mode, &name);
43 let capabilities = ServerCapabilities::builder().enable_tools().build();
44
45 Ok(InitializeResult::new(capabilities)
46 .with_server_info(Implementation::new("lean-ctx", "2.16.6"))
47 .with_instructions(instructions))
48 }
49
50 async fn list_tools(
51 &self,
52 _request: Option<PaginatedRequestParams>,
53 _context: RequestContext<RoleServer>,
54 ) -> Result<ListToolsResult, ErrorData> {
55 if should_use_unified(&self.client_name.read().await) {
56 return Ok(ListToolsResult {
57 tools: unified_tool_defs(),
58 ..Default::default()
59 });
60 }
61
62 Ok(ListToolsResult {
63 tools: vec![
64 tool_def(
65 "ctx_read",
66 "Read file (cached, compressed). Re-reads ~13 tok. Auto-selects optimal mode. \
67Modes: full|map|signatures|diff|aggressive|entropy|task|reference|lines:N-M. fresh=true re-reads.",
68 json!({
69 "type": "object",
70 "properties": {
71 "path": { "type": "string", "description": "Absolute file path to read" },
72 "mode": {
73 "type": "string",
74 "description": "Compression mode (default: full). Use 'map' for context-only files. For line ranges: 'lines:N-M' (e.g. 'lines:400-500')."
75 },
76 "start_line": {
77 "type": "integer",
78 "description": "Read from this line number to end of file. Bypasses cache stub — always returns actual content."
79 },
80 "fresh": {
81 "type": "boolean",
82 "description": "Bypass cache and force a full re-read. Use when running as a subagent that may not have the parent's context."
83 }
84 },
85 "required": ["path"]
86 }),
87 ),
88 tool_def(
89 "ctx_multi_read",
90 "Batch read files in one call. Same modes as ctx_read.",
91 json!({
92 "type": "object",
93 "properties": {
94 "paths": {
95 "type": "array",
96 "items": { "type": "string" },
97 "description": "Absolute file paths to read, in order"
98 },
99 "mode": {
100 "type": "string",
101 "enum": ["full", "signatures", "map", "diff", "aggressive", "entropy"],
102 "description": "Compression mode (default: full)"
103 }
104 },
105 "required": ["paths"]
106 }),
107 ),
108 tool_def(
109 "ctx_tree",
110 "Directory listing with file counts.",
111 json!({
112 "type": "object",
113 "properties": {
114 "path": { "type": "string", "description": "Directory path (default: .)" },
115 "depth": { "type": "integer", "description": "Max depth (default: 3)" },
116 "show_hidden": { "type": "boolean", "description": "Show hidden files" }
117 }
118 }),
119 ),
120 tool_def(
121 "ctx_shell",
122 "Run shell command (compressed output, 90+ patterns). Use raw=true to skip compression and get full output.",
123 json!({
124 "type": "object",
125 "properties": {
126 "command": { "type": "string", "description": "Shell command to execute" },
127 "raw": { "type": "boolean", "description": "Skip compression, return full uncompressed output. Use for small outputs or when full detail is critical." }
128 },
129 "required": ["command"]
130 }),
131 ),
132 tool_def(
133 "ctx_search",
134 "Regex code search (.gitignore aware, compact results).",
135 json!({
136 "type": "object",
137 "properties": {
138 "pattern": { "type": "string", "description": "Regex pattern" },
139 "path": { "type": "string", "description": "Directory to search" },
140 "ext": { "type": "string", "description": "File extension filter" },
141 "max_results": { "type": "integer", "description": "Max results (default: 20)" },
142 "ignore_gitignore": { "type": "boolean", "description": "Set true to scan ALL files including .gitignore'd paths (default: false)" }
143 },
144 "required": ["pattern"]
145 }),
146 ),
147 tool_def(
148 "ctx_compress",
149 "Context checkpoint for long conversations.",
150 json!({
151 "type": "object",
152 "properties": {
153 "include_signatures": { "type": "boolean", "description": "Include signatures (default: true)" }
154 }
155 }),
156 ),
157 tool_def(
158 "ctx_benchmark",
159 "Benchmark compression modes for a file or project.",
160 json!({
161 "type": "object",
162 "properties": {
163 "path": { "type": "string", "description": "File path (action=file) or project directory (action=project)" },
164 "action": { "type": "string", "description": "file (default) or project", "default": "file" },
165 "format": { "type": "string", "description": "Output format for project benchmark: terminal, markdown, json", "default": "terminal" }
166 },
167 "required": ["path"]
168 }),
169 ),
170 tool_def(
171 "ctx_metrics",
172 "Session token stats, cache rates, per-tool savings.",
173 json!({
174 "type": "object",
175 "properties": {}
176 }),
177 ),
178 tool_def(
179 "ctx_analyze",
180 "Entropy analysis — recommends optimal compression mode for a file.",
181 json!({
182 "type": "object",
183 "properties": {
184 "path": { "type": "string", "description": "File path to analyze" }
185 },
186 "required": ["path"]
187 }),
188 ),
189 tool_def(
190 "ctx_cache",
191 "Cache ops: status|clear|invalidate.",
192 json!({
193 "type": "object",
194 "properties": {
195 "action": {
196 "type": "string",
197 "enum": ["status", "clear", "invalidate"],
198 "description": "Cache operation to perform"
199 },
200 "path": {
201 "type": "string",
202 "description": "File path (required for 'invalidate' action)"
203 }
204 },
205 "required": ["action"]
206 }),
207 ),
208 tool_def(
209 "ctx_discover",
210 "Find missed compression opportunities in shell history.",
211 json!({
212 "type": "object",
213 "properties": {
214 "limit": {
215 "type": "integer",
216 "description": "Max number of command types to show (default: 15)"
217 }
218 }
219 }),
220 ),
221 tool_def(
222 "ctx_smart_read",
223 "Auto-select optimal read mode for a file.",
224 json!({
225 "type": "object",
226 "properties": {
227 "path": { "type": "string", "description": "Absolute file path to read" }
228 },
229 "required": ["path"]
230 }),
231 ),
232 tool_def(
233 "ctx_delta",
234 "Incremental diff — sends only changed lines since last read.",
235 json!({
236 "type": "object",
237 "properties": {
238 "path": { "type": "string", "description": "Absolute file path" }
239 },
240 "required": ["path"]
241 }),
242 ),
243 tool_def(
244 "ctx_edit",
245 "Edit a file via search-and-replace. Works without native Read/Edit tools. Use this when the IDE's Edit tool requires Read but Read is unavailable.",
246 json!({
247 "type": "object",
248 "properties": {
249 "path": { "type": "string", "description": "Absolute file path" },
250 "old_string": { "type": "string", "description": "Exact text to find and replace (must be unique unless replace_all=true)" },
251 "new_string": { "type": "string", "description": "Replacement text" },
252 "replace_all": { "type": "boolean", "description": "Replace all occurrences (default: false)", "default": false },
253 "create": { "type": "boolean", "description": "Create a new file with new_string as content (ignores old_string)", "default": false }
254 },
255 "required": ["path", "new_string"]
256 }),
257 ),
258 tool_def(
259 "ctx_dedup",
260 "Cross-file dedup: analyze or apply shared block references.",
261 json!({
262 "type": "object",
263 "properties": {
264 "action": {
265 "type": "string",
266 "description": "analyze (default) or apply (register shared blocks for auto-dedup in ctx_read)",
267 "default": "analyze"
268 }
269 }
270 }),
271 ),
272 tool_def(
273 "ctx_fill",
274 "Budget-aware context fill — auto-selects compression per file within token limit.",
275 json!({
276 "type": "object",
277 "properties": {
278 "paths": {
279 "type": "array",
280 "items": { "type": "string" },
281 "description": "File paths to consider"
282 },
283 "budget": {
284 "type": "integer",
285 "description": "Maximum token budget to fill"
286 }
287 },
288 "required": ["paths", "budget"]
289 }),
290 ),
291 tool_def(
292 "ctx_intent",
293 "Intent detection — auto-reads relevant files based on task description.",
294 json!({
295 "type": "object",
296 "properties": {
297 "query": { "type": "string", "description": "Natural language description of the task" },
298 "project_root": { "type": "string", "description": "Project root directory (default: .)" }
299 },
300 "required": ["query"]
301 }),
302 ),
303 tool_def(
304 "ctx_response",
305 "Compress LLM response text (remove filler, apply TDD).",
306 json!({
307 "type": "object",
308 "properties": {
309 "text": { "type": "string", "description": "Response text to compress" }
310 },
311 "required": ["text"]
312 }),
313 ),
314 tool_def(
315 "ctx_context",
316 "Session context overview — cached files, seen files, session state.",
317 json!({
318 "type": "object",
319 "properties": {}
320 }),
321 ),
322 tool_def(
323 "ctx_graph",
324 "Code dependency graph. Actions: build (index project), related (find files connected to path), \
325symbol (lookup definition/usages as file::name), impact (blast radius of changes to path), status (index stats).",
326 json!({
327 "type": "object",
328 "properties": {
329 "action": {
330 "type": "string",
331 "enum": ["build", "related", "symbol", "impact", "status"],
332 "description": "Graph operation: build, related, symbol, impact, status"
333 },
334 "path": {
335 "type": "string",
336 "description": "File path (related/impact) or file::symbol_name (symbol)"
337 },
338 "project_root": {
339 "type": "string",
340 "description": "Project root directory (default: .)"
341 }
342 },
343 "required": ["action"]
344 }),
345 ),
346 tool_def(
347 "ctx_session",
348 "Cross-session memory (CCP). Actions: load (restore previous session ~400 tok), \
349save, status, task (set current task), finding (record discovery), decision (record choice), \
350reset, list (show sessions), cleanup.",
351 json!({
352 "type": "object",
353 "properties": {
354 "action": {
355 "type": "string",
356 "enum": ["status", "load", "save", "task", "finding", "decision", "reset", "list", "cleanup"],
357 "description": "Session operation to perform"
358 },
359 "value": {
360 "type": "string",
361 "description": "Value for task/finding/decision actions"
362 },
363 "session_id": {
364 "type": "string",
365 "description": "Session ID for load action (default: latest)"
366 }
367 },
368 "required": ["action"]
369 }),
370 ),
371 tool_def(
372 "ctx_knowledge",
373 "Persistent project knowledge (survives sessions). Actions: remember (store fact with category+key+value), \
374recall (search by query), pattern (record naming/structure pattern), consolidate (extract session findings into knowledge), \
375status (list all), remove, export.",
376 json!({
377 "type": "object",
378 "properties": {
379 "action": {
380 "type": "string",
381 "enum": ["remember", "recall", "pattern", "consolidate", "status", "remove", "export"],
382 "description": "Knowledge operation to perform"
383 },
384 "category": {
385 "type": "string",
386 "description": "Fact category (architecture, api, testing, deployment, conventions, dependencies)"
387 },
388 "key": {
389 "type": "string",
390 "description": "Fact key/identifier (e.g. 'auth-method', 'db-engine', 'test-framework')"
391 },
392 "value": {
393 "type": "string",
394 "description": "Fact value or pattern description"
395 },
396 "query": {
397 "type": "string",
398 "description": "Search query for recall action (matches against category, key, and value)"
399 },
400 "pattern_type": {
401 "type": "string",
402 "description": "Pattern type for pattern action (naming, structure, testing, error-handling)"
403 },
404 "examples": {
405 "type": "array",
406 "items": { "type": "string" },
407 "description": "Examples for pattern action"
408 },
409 "confidence": {
410 "type": "number",
411 "description": "Confidence score 0.0-1.0 for remember action (default: 0.8)"
412 }
413 },
414 "required": ["action"]
415 }),
416 ),
417 tool_def(
418 "ctx_agent",
419 "Multi-agent coordination (shared message bus). Actions: register (join with agent_type+role), \
420post (broadcast or direct message with category), read (poll messages), status (update state: active|idle|finished), \
421list, info.",
422 json!({
423 "type": "object",
424 "properties": {
425 "action": {
426 "type": "string",
427 "enum": ["register", "list", "post", "read", "status", "info"],
428 "description": "Agent operation to perform"
429 },
430 "agent_type": {
431 "type": "string",
432 "description": "Agent type for register (cursor, claude, codex, gemini, subagent)"
433 },
434 "role": {
435 "type": "string",
436 "description": "Agent role (dev, review, test, plan)"
437 },
438 "message": {
439 "type": "string",
440 "description": "Message text for post action, or status detail for status action"
441 },
442 "category": {
443 "type": "string",
444 "description": "Message category for post (finding, warning, request, status)"
445 },
446 "to_agent": {
447 "type": "string",
448 "description": "Target agent ID for direct message (omit for broadcast)"
449 },
450 "status": {
451 "type": "string",
452 "enum": ["active", "idle", "finished"],
453 "description": "New status for status action"
454 }
455 },
456 "required": ["action"]
457 }),
458 ),
459 tool_def(
460 "ctx_overview",
461 "Task-relevant project map — use at session start.",
462 json!({
463 "type": "object",
464 "properties": {
465 "task": {
466 "type": "string",
467 "description": "Task description for relevance scoring (e.g. 'fix auth bug in login flow')"
468 },
469 "path": {
470 "type": "string",
471 "description": "Project root directory (default: .)"
472 }
473 }
474 }),
475 ),
476 tool_def(
477 "ctx_preload",
478 "Proactive context loader — caches task-relevant files, returns L-curve-optimized summary (~50-100 tokens vs ~5000 for individual reads).",
479 json!({
480 "type": "object",
481 "properties": {
482 "task": {
483 "type": "string",
484 "description": "Task description (e.g. 'fix auth bug in validate_token')"
485 },
486 "path": {
487 "type": "string",
488 "description": "Project root (default: .)"
489 }
490 },
491 "required": ["task"]
492 }),
493 ),
494 tool_def(
495 "ctx_wrapped",
496 "Savings report card. Periods: week|month|all.",
497 json!({
498 "type": "object",
499 "properties": {
500 "period": {
501 "type": "string",
502 "enum": ["week", "month", "all"],
503 "description": "Report period (default: week)"
504 }
505 }
506 }),
507 ),
508 tool_def(
509 "ctx_semantic_search",
510 "BM25 code search by meaning. action=reindex to rebuild.",
511 json!({
512 "type": "object",
513 "properties": {
514 "query": { "type": "string", "description": "Natural language search query" },
515 "path": { "type": "string", "description": "Project root to search (default: .)" },
516 "top_k": { "type": "integer", "description": "Number of results (default: 10)" },
517 "action": { "type": "string", "description": "reindex to rebuild index" }
518 },
519 "required": ["query"]
520 }),
521 ),
522 ],
523 ..Default::default()
524 })
525 }
526
527 async fn call_tool(
528 &self,
529 request: CallToolRequestParams,
530 _context: RequestContext<RoleServer>,
531 ) -> Result<CallToolResult, ErrorData> {
532 self.check_idle_expiry().await;
533
534 let original_name = request.name.as_ref().to_string();
535 let (resolved_name, resolved_args) = if original_name == "ctx" {
536 let sub = request
537 .arguments
538 .as_ref()
539 .and_then(|a| a.get("tool"))
540 .and_then(|v| v.as_str())
541 .map(|s| s.to_string())
542 .ok_or_else(|| {
543 ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
544 })?;
545 let tool_name = if sub.starts_with("ctx_") {
546 sub
547 } else {
548 format!("ctx_{sub}")
549 };
550 let mut args = request.arguments.unwrap_or_default();
551 args.remove("tool");
552 (tool_name, Some(args))
553 } else {
554 (original_name, request.arguments)
555 };
556 let name = resolved_name.as_str();
557 let args = &resolved_args;
558
559 let auto_context = {
560 let task = {
561 let session = self.session.read().await;
562 session.task.as_ref().map(|t| t.description.clone())
563 };
564 let project_root = {
565 let session = self.session.read().await;
566 session.project_root.clone()
567 };
568 let mut cache = self.cache.write().await;
569 crate::tools::autonomy::session_lifecycle_pre_hook(
570 &self.autonomy,
571 name,
572 &mut cache,
573 task.as_deref(),
574 project_root.as_deref(),
575 self.crp_mode,
576 )
577 };
578
579 let tool_start = std::time::Instant::now();
580 let result_text = match name {
581 "ctx_read" => {
582 let path = get_str(args, "path")
583 .map(|p| crate::hooks::normalize_tool_path(&p))
584 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
585 let current_task = {
586 let session = self.session.read().await;
587 session.task.as_ref().map(|t| t.description.clone())
588 };
589 let task_ref = current_task.as_deref();
590 let mut mode = match get_str(args, "mode") {
591 Some(m) => m,
592 None => {
593 let cache = self.cache.read().await;
594 crate::tools::ctx_smart_read::select_mode_with_task(&cache, &path, task_ref)
595 }
596 };
597 let fresh = get_bool(args, "fresh").unwrap_or(false);
598 let start_line = get_int(args, "start_line");
599 if let Some(sl) = start_line {
600 let sl = sl.max(1_i64);
601 mode = format!("lines:{sl}-999999");
602 }
603 let stale = self.is_prompt_cache_stale().await;
604 let effective_mode = LeanCtxServer::upgrade_mode_if_stale(&mode, stale).to_string();
605 let mut cache = self.cache.write().await;
606 let output = if fresh {
607 crate::tools::ctx_read::handle_fresh_with_task(
608 &mut cache,
609 &path,
610 &effective_mode,
611 self.crp_mode,
612 task_ref,
613 )
614 } else {
615 crate::tools::ctx_read::handle_with_task(
616 &mut cache,
617 &path,
618 &effective_mode,
619 self.crp_mode,
620 task_ref,
621 )
622 };
623 let stale_note = if effective_mode != mode {
624 format!("[cache stale, {mode}→{effective_mode}]\n")
625 } else {
626 String::new()
627 };
628 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
629 let output_tokens = crate::core::tokens::count_tokens(&output);
630 let saved = original.saturating_sub(output_tokens);
631 let is_cache_hit = output.contains(" cached ");
632 let output = format!("{stale_note}{output}");
633 let file_ref = cache.file_ref_map().get(&path).cloned();
634 drop(cache);
635 {
636 let mut session = self.session.write().await;
637 session.touch_file(&path, file_ref.as_deref(), &effective_mode, original);
638 if is_cache_hit {
639 session.record_cache_hit();
640 }
641 if session.project_root.is_none() {
642 if let Some(root) = detect_project_root(&path) {
643 session.project_root = Some(root.clone());
644 let mut current = self.agent_id.write().await;
645 if current.is_none() {
646 let mut registry =
647 crate::core::agents::AgentRegistry::load_or_create();
648 registry.cleanup_stale(24);
649 let id = registry.register("mcp", None, &root);
650 let _ = registry.save();
651 *current = Some(id);
652 }
653 }
654 }
655 }
656 self.record_call("ctx_read", original, saved, Some(mode.clone()))
657 .await;
658 {
659 let sig =
660 crate::core::mode_predictor::FileSignature::from_path(&path, original);
661 let density = if output_tokens > 0 {
662 original as f64 / output_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: output_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: saved 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 .into_iter()
707 .map(|p| crate::hooks::normalize_tool_path(&p))
708 .collect::<Vec<_>>();
709 let mode = get_str(args, "mode").unwrap_or_else(|| "full".to_string());
710 let current_task = {
711 let session = self.session.read().await;
712 session.task.as_ref().map(|t| t.description.clone())
713 };
714 let mut cache = self.cache.write().await;
715 let output = crate::tools::ctx_multi_read::handle_with_task(
716 &mut cache,
717 &paths,
718 &mode,
719 self.crp_mode,
720 current_task.as_deref(),
721 );
722 let mut total_original: usize = 0;
723 for path in &paths {
724 total_original = total_original
725 .saturating_add(cache.get(path).map(|e| e.original_tokens).unwrap_or(0));
726 }
727 let tokens = crate::core::tokens::count_tokens(&output);
728 drop(cache);
729 self.record_call(
730 "ctx_multi_read",
731 total_original,
732 total_original.saturating_sub(tokens),
733 Some(mode),
734 )
735 .await;
736 output
737 }
738 "ctx_tree" => {
739 let path = crate::hooks::normalize_tool_path(
740 &get_str(args, "path").unwrap_or_else(|| ".".to_string()),
741 );
742 let depth = get_int(args, "depth").unwrap_or(3) as usize;
743 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
744 let (result, original) = crate::tools::ctx_tree::handle(&path, depth, show_hidden);
745 let sent = crate::core::tokens::count_tokens(&result);
746 let saved = original.saturating_sub(sent);
747 self.record_call("ctx_tree", original, saved, None).await;
748 let savings_note = if saved > 0 {
749 format!("\n[saved {saved} tokens vs native ls]")
750 } else {
751 String::new()
752 };
753 format!("{result}{savings_note}")
754 }
755 "ctx_shell" => {
756 let command = get_str(args, "command")
757 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
758 let raw = get_bool(args, "raw").unwrap_or(false)
759 || std::env::var("LEAN_CTX_DISABLED").is_ok();
760 let output = execute_command(&command);
761
762 if raw {
763 let original = crate::core::tokens::count_tokens(&output);
764 self.record_call("ctx_shell", original, 0, None).await;
765 output
766 } else {
767 let result = crate::tools::ctx_shell::handle(&command, &output, self.crp_mode);
768 let original = crate::core::tokens::count_tokens(&output);
769 let sent = crate::core::tokens::count_tokens(&result);
770 let saved = original.saturating_sub(sent);
771 self.record_call("ctx_shell", original, saved, None).await;
772
773 let cfg = crate::core::config::Config::load();
774 let tee_hint = match cfg.tee_mode {
775 crate::core::config::TeeMode::Always => {
776 crate::shell::save_tee(&command, &output)
777 .map(|p| format!("\n[full output: {p}]"))
778 .unwrap_or_default()
779 }
780 crate::core::config::TeeMode::Failures
781 if !output.trim().is_empty() && output.contains("error")
782 || output.contains("Error")
783 || output.contains("ERROR") =>
784 {
785 crate::shell::save_tee(&command, &output)
786 .map(|p| format!("\n[full output: {p}]"))
787 .unwrap_or_default()
788 }
789 _ => String::new(),
790 };
791
792 let savings_note = if saved > 0 {
793 format!("\n[saved {saved} tokens vs native Shell]")
794 } else {
795 String::new()
796 };
797 format!("{result}{savings_note}{tee_hint}")
798 }
799 }
800 "ctx_search" => {
801 let pattern = get_str(args, "pattern")
802 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
803 let path = crate::hooks::normalize_tool_path(
804 &get_str(args, "path").unwrap_or_else(|| ".".to_string()),
805 );
806 let ext = get_str(args, "ext");
807 let max = get_int(args, "max_results").unwrap_or(20) as usize;
808 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
809 let crp = self.crp_mode;
810 let respect = !no_gitignore;
811 let search_result = tokio::time::timeout(
812 std::time::Duration::from_secs(30),
813 tokio::task::spawn_blocking(move || {
814 crate::tools::ctx_search::handle(
815 &pattern,
816 &path,
817 ext.as_deref(),
818 max,
819 crp,
820 respect,
821 )
822 }),
823 )
824 .await;
825 let (result, original) = match search_result {
826 Ok(Ok(r)) => r,
827 Ok(Err(e)) => {
828 return Err(ErrorData::internal_error(
829 format!("search task failed: {e}"),
830 None,
831 ))
832 }
833 Err(_) => {
834 let msg = "ctx_search timed out after 30s. Try narrowing the search:\n\
835 • Use a more specific pattern\n\
836 • Specify ext= to limit file types\n\
837 • Specify a subdirectory in path=";
838 self.record_call("ctx_search", 0, 0, None).await;
839 return Ok(CallToolResult::success(vec![Content::text(msg)]));
840 }
841 };
842 let sent = crate::core::tokens::count_tokens(&result);
843 let saved = original.saturating_sub(sent);
844 self.record_call("ctx_search", original, saved, None).await;
845 let savings_note = if saved > 0 {
846 format!("\n[saved {saved} tokens vs native Grep]")
847 } else {
848 String::new()
849 };
850 format!("{result}{savings_note}")
851 }
852 "ctx_compress" => {
853 let include_sigs = get_bool(args, "include_signatures").unwrap_or(true);
854 let cache = self.cache.read().await;
855 let result =
856 crate::tools::ctx_compress::handle(&cache, include_sigs, self.crp_mode);
857 drop(cache);
858 self.record_call("ctx_compress", 0, 0, None).await;
859 result
860 }
861 "ctx_benchmark" => {
862 let path = get_str(args, "path")
863 .map(|p| crate::hooks::normalize_tool_path(&p))
864 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
865 let action = get_str(args, "action").unwrap_or_default();
866 let result = if action == "project" {
867 let fmt = get_str(args, "format").unwrap_or_default();
868 let bench = crate::core::benchmark::run_project_benchmark(&path);
869 match fmt.as_str() {
870 "json" => crate::core::benchmark::format_json(&bench),
871 "markdown" | "md" => crate::core::benchmark::format_markdown(&bench),
872 _ => crate::core::benchmark::format_terminal(&bench),
873 }
874 } else {
875 crate::tools::ctx_benchmark::handle(&path, self.crp_mode)
876 };
877 self.record_call("ctx_benchmark", 0, 0, None).await;
878 result
879 }
880 "ctx_metrics" => {
881 let cache = self.cache.read().await;
882 let calls = self.tool_calls.read().await;
883 let result = crate::tools::ctx_metrics::handle(&cache, &calls, self.crp_mode);
884 drop(cache);
885 drop(calls);
886 self.record_call("ctx_metrics", 0, 0, None).await;
887 result
888 }
889 "ctx_analyze" => {
890 let path = get_str(args, "path")
891 .map(|p| crate::hooks::normalize_tool_path(&p))
892 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
893 let result = crate::tools::ctx_analyze::handle(&path, self.crp_mode);
894 self.record_call("ctx_analyze", 0, 0, None).await;
895 result
896 }
897 "ctx_discover" => {
898 let limit = get_int(args, "limit").unwrap_or(15) as usize;
899 let history = crate::cli::load_shell_history_pub();
900 let result = crate::tools::ctx_discover::discover_from_history(&history, limit);
901 self.record_call("ctx_discover", 0, 0, None).await;
902 result
903 }
904 "ctx_smart_read" => {
905 let path = get_str(args, "path")
906 .map(|p| crate::hooks::normalize_tool_path(&p))
907 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
908 let mut cache = self.cache.write().await;
909 let output = crate::tools::ctx_smart_read::handle(&mut cache, &path, self.crp_mode);
910 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
911 let tokens = crate::core::tokens::count_tokens(&output);
912 drop(cache);
913 self.record_call(
914 "ctx_smart_read",
915 original,
916 original.saturating_sub(tokens),
917 Some("auto".to_string()),
918 )
919 .await;
920 output
921 }
922 "ctx_delta" => {
923 let path = get_str(args, "path")
924 .map(|p| crate::hooks::normalize_tool_path(&p))
925 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
926 let mut cache = self.cache.write().await;
927 let output = crate::tools::ctx_delta::handle(&mut cache, &path);
928 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
929 let tokens = crate::core::tokens::count_tokens(&output);
930 drop(cache);
931 {
932 let mut session = self.session.write().await;
933 session.mark_modified(&path);
934 }
935 self.record_call(
936 "ctx_delta",
937 original,
938 original.saturating_sub(tokens),
939 Some("delta".to_string()),
940 )
941 .await;
942 output
943 }
944 "ctx_edit" => {
945 let path = get_str(args, "path")
946 .map(|p| crate::hooks::normalize_tool_path(&p))
947 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
948 let old_string = get_str(args, "old_string").unwrap_or_default();
949 let new_string = get_str(args, "new_string")
950 .ok_or_else(|| ErrorData::invalid_params("new_string is required", None))?;
951 let replace_all = args
952 .as_ref()
953 .and_then(|a| a.get("replace_all"))
954 .and_then(|v| v.as_bool())
955 .unwrap_or(false);
956 let create = args
957 .as_ref()
958 .and_then(|a| a.get("create"))
959 .and_then(|v| v.as_bool())
960 .unwrap_or(false);
961
962 let mut cache = self.cache.write().await;
963 let output = crate::tools::ctx_edit::handle(
964 &mut cache,
965 crate::tools::ctx_edit::EditParams {
966 path: path.clone(),
967 old_string,
968 new_string,
969 replace_all,
970 create,
971 },
972 );
973 drop(cache);
974
975 {
976 let mut session = self.session.write().await;
977 session.mark_modified(&path);
978 }
979 self.record_call("ctx_edit", 0, 0, None).await;
980 output
981 }
982 "ctx_dedup" => {
983 let action = get_str(args, "action").unwrap_or_default();
984 if action == "apply" {
985 let mut cache = self.cache.write().await;
986 let result = crate::tools::ctx_dedup::handle_action(&mut cache, &action);
987 drop(cache);
988 self.record_call("ctx_dedup", 0, 0, None).await;
989 result
990 } else {
991 let cache = self.cache.read().await;
992 let result = crate::tools::ctx_dedup::handle(&cache);
993 drop(cache);
994 self.record_call("ctx_dedup", 0, 0, None).await;
995 result
996 }
997 }
998 "ctx_fill" => {
999 let paths = get_str_array(args, "paths")
1000 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?
1001 .into_iter()
1002 .map(|p| crate::hooks::normalize_tool_path(&p))
1003 .collect::<Vec<_>>();
1004 let budget = get_int(args, "budget")
1005 .ok_or_else(|| ErrorData::invalid_params("budget is required", None))?
1006 as usize;
1007 let mut cache = self.cache.write().await;
1008 let output =
1009 crate::tools::ctx_fill::handle(&mut cache, &paths, budget, self.crp_mode);
1010 drop(cache);
1011 self.record_call("ctx_fill", 0, 0, Some(format!("budget:{budget}")))
1012 .await;
1013 output
1014 }
1015 "ctx_intent" => {
1016 let query = get_str(args, "query")
1017 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
1018 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
1019 let mut cache = self.cache.write().await;
1020 let output =
1021 crate::tools::ctx_intent::handle(&mut cache, &query, &root, self.crp_mode);
1022 drop(cache);
1023 {
1024 let mut session = self.session.write().await;
1025 session.set_task(&query, Some("intent"));
1026 }
1027 self.record_call("ctx_intent", 0, 0, Some("semantic".to_string()))
1028 .await;
1029 output
1030 }
1031 "ctx_response" => {
1032 let text = get_str(args, "text")
1033 .ok_or_else(|| ErrorData::invalid_params("text is required", None))?;
1034 let output = crate::tools::ctx_response::handle(&text, self.crp_mode);
1035 self.record_call("ctx_response", 0, 0, None).await;
1036 output
1037 }
1038 "ctx_context" => {
1039 let cache = self.cache.read().await;
1040 let turn = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1041 let result = crate::tools::ctx_context::handle_status(&cache, turn, self.crp_mode);
1042 drop(cache);
1043 self.record_call("ctx_context", 0, 0, None).await;
1044 result
1045 }
1046 "ctx_graph" => {
1047 let action = get_str(args, "action")
1048 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1049 let path = get_str(args, "path").map(|p| crate::hooks::normalize_tool_path(&p));
1050 let root = crate::hooks::normalize_tool_path(
1051 &get_str(args, "project_root").unwrap_or_else(|| ".".to_string()),
1052 );
1053 let mut cache = self.cache.write().await;
1054 let result = crate::tools::ctx_graph::handle(
1055 &action,
1056 path.as_deref(),
1057 &root,
1058 &mut cache,
1059 self.crp_mode,
1060 );
1061 drop(cache);
1062 self.record_call("ctx_graph", 0, 0, Some(action)).await;
1063 result
1064 }
1065 "ctx_cache" => {
1066 let action = get_str(args, "action")
1067 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1068 let mut cache = self.cache.write().await;
1069 let result = match action.as_str() {
1070 "status" => {
1071 let entries = cache.get_all_entries();
1072 if entries.is_empty() {
1073 "Cache empty — no files tracked.".to_string()
1074 } else {
1075 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
1076 for (path, entry) in &entries {
1077 let fref = cache
1078 .file_ref_map()
1079 .get(*path)
1080 .map(|s| s.as_str())
1081 .unwrap_or("F?");
1082 lines.push(format!(
1083 " {fref}={} [{}L, {}t, read {}x]",
1084 crate::core::protocol::shorten_path(path),
1085 entry.line_count,
1086 entry.original_tokens,
1087 entry.read_count
1088 ));
1089 }
1090 lines.join("\n")
1091 }
1092 }
1093 "clear" => {
1094 let count = cache.clear();
1095 format!("Cache cleared — {count} file(s) removed. Next ctx_read will return full content.")
1096 }
1097 "invalidate" => {
1098 let path = get_str(args, "path")
1099 .map(|p| crate::hooks::normalize_tool_path(&p))
1100 .ok_or_else(|| {
1101 ErrorData::invalid_params("path is required for invalidate", None)
1102 })?;
1103 if cache.invalidate(&path) {
1104 format!(
1105 "Invalidated cache for {}. Next ctx_read will return full content.",
1106 crate::core::protocol::shorten_path(&path)
1107 )
1108 } else {
1109 format!(
1110 "{} was not in cache.",
1111 crate::core::protocol::shorten_path(&path)
1112 )
1113 }
1114 }
1115 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
1116 };
1117 drop(cache);
1118 self.record_call("ctx_cache", 0, 0, Some(action)).await;
1119 result
1120 }
1121 "ctx_session" => {
1122 let action = get_str(args, "action")
1123 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1124 let value = get_str(args, "value");
1125 let sid = get_str(args, "session_id");
1126 let mut session = self.session.write().await;
1127 let result = crate::tools::ctx_session::handle(
1128 &mut session,
1129 &action,
1130 value.as_deref(),
1131 sid.as_deref(),
1132 );
1133 drop(session);
1134 self.record_call("ctx_session", 0, 0, Some(action)).await;
1135 result
1136 }
1137 "ctx_knowledge" => {
1138 let action = get_str(args, "action")
1139 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1140 let category = get_str(args, "category");
1141 let key = get_str(args, "key");
1142 let value = get_str(args, "value");
1143 let query = get_str(args, "query");
1144 let pattern_type = get_str(args, "pattern_type");
1145 let examples = get_str_array(args, "examples");
1146 let confidence: Option<f32> = args
1147 .as_ref()
1148 .and_then(|a| a.get("confidence"))
1149 .and_then(|v| v.as_f64())
1150 .map(|v| v as f32);
1151
1152 let session = self.session.read().await;
1153 let session_id = session.id.clone();
1154 let project_root = session.project_root.clone().unwrap_or_else(|| {
1155 std::env::current_dir()
1156 .map(|p| p.to_string_lossy().to_string())
1157 .unwrap_or_else(|_| "unknown".to_string())
1158 });
1159 drop(session);
1160
1161 let result = crate::tools::ctx_knowledge::handle(
1162 &project_root,
1163 &action,
1164 category.as_deref(),
1165 key.as_deref(),
1166 value.as_deref(),
1167 query.as_deref(),
1168 &session_id,
1169 pattern_type.as_deref(),
1170 examples,
1171 confidence,
1172 );
1173 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
1174 result
1175 }
1176 "ctx_agent" => {
1177 let action = get_str(args, "action")
1178 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1179 let agent_type = get_str(args, "agent_type");
1180 let role = get_str(args, "role");
1181 let message = get_str(args, "message");
1182 let category = get_str(args, "category");
1183 let to_agent = get_str(args, "to_agent");
1184 let status = get_str(args, "status");
1185
1186 let session = self.session.read().await;
1187 let project_root = session.project_root.clone().unwrap_or_else(|| {
1188 std::env::current_dir()
1189 .map(|p| p.to_string_lossy().to_string())
1190 .unwrap_or_else(|_| "unknown".to_string())
1191 });
1192 drop(session);
1193
1194 let current_agent_id = self.agent_id.read().await.clone();
1195 let result = crate::tools::ctx_agent::handle(
1196 &action,
1197 agent_type.as_deref(),
1198 role.as_deref(),
1199 &project_root,
1200 current_agent_id.as_deref(),
1201 message.as_deref(),
1202 category.as_deref(),
1203 to_agent.as_deref(),
1204 status.as_deref(),
1205 );
1206
1207 if action == "register" {
1208 if let Some(id) = result.split(':').nth(1) {
1209 let id = id.split_whitespace().next().unwrap_or("").to_string();
1210 if !id.is_empty() {
1211 *self.agent_id.write().await = Some(id);
1212 }
1213 }
1214 }
1215
1216 self.record_call("ctx_agent", 0, 0, Some(action)).await;
1217 result
1218 }
1219 "ctx_overview" => {
1220 let task = get_str(args, "task");
1221 let path = get_str(args, "path").map(|p| crate::hooks::normalize_tool_path(&p));
1222 let cache = self.cache.read().await;
1223 let result = crate::tools::ctx_overview::handle(
1224 &cache,
1225 task.as_deref(),
1226 path.as_deref(),
1227 self.crp_mode,
1228 );
1229 drop(cache);
1230 self.record_call("ctx_overview", 0, 0, Some("overview".to_string()))
1231 .await;
1232 result
1233 }
1234 "ctx_preload" => {
1235 let task = get_str(args, "task").unwrap_or_default();
1236 let path = get_str(args, "path").map(|p| crate::hooks::normalize_tool_path(&p));
1237 let mut cache = self.cache.write().await;
1238 let result = crate::tools::ctx_preload::handle(
1239 &mut cache,
1240 &task,
1241 path.as_deref(),
1242 self.crp_mode,
1243 );
1244 drop(cache);
1245 self.record_call("ctx_preload", 0, 0, Some("preload".to_string()))
1246 .await;
1247 result
1248 }
1249 "ctx_wrapped" => {
1250 let period = get_str(args, "period").unwrap_or_else(|| "week".to_string());
1251 let result = crate::tools::ctx_wrapped::handle(&period);
1252 self.record_call("ctx_wrapped", 0, 0, Some(period)).await;
1253 result
1254 }
1255 "ctx_semantic_search" => {
1256 let query = get_str(args, "query")
1257 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
1258 let path = crate::hooks::normalize_tool_path(
1259 &get_str(args, "path").unwrap_or_else(|| ".".to_string()),
1260 );
1261 let top_k = get_int(args, "top_k").unwrap_or(10) as usize;
1262 let action = get_str(args, "action").unwrap_or_default();
1263 let result = if action == "reindex" {
1264 crate::tools::ctx_semantic_search::handle_reindex(&path)
1265 } else {
1266 crate::tools::ctx_semantic_search::handle(&query, &path, top_k, self.crp_mode)
1267 };
1268 self.record_call("ctx_semantic_search", 0, 0, Some("semantic".to_string()))
1269 .await;
1270 result
1271 }
1272 _ => {
1273 return Err(ErrorData::invalid_params(
1274 format!("Unknown tool: {name}"),
1275 None,
1276 ));
1277 }
1278 };
1279
1280 let mut result_text = result_text;
1281
1282 if let Some(ctx) = auto_context {
1283 result_text = format!("{ctx}\n\n{result_text}");
1284 }
1285
1286 if name == "ctx_read" {
1287 let read_path =
1288 crate::hooks::normalize_tool_path(&get_str(args, "path").unwrap_or_default());
1289 let project_root = {
1290 let session = self.session.read().await;
1291 session.project_root.clone()
1292 };
1293 let mut cache = self.cache.write().await;
1294 let enrich = crate::tools::autonomy::enrich_after_read(
1295 &self.autonomy,
1296 &mut cache,
1297 &read_path,
1298 project_root.as_deref(),
1299 );
1300 if let Some(hint) = enrich.related_hint {
1301 result_text = format!("{result_text}\n{hint}");
1302 }
1303
1304 crate::tools::autonomy::maybe_auto_dedup(&self.autonomy, &mut cache);
1305 }
1306
1307 if name == "ctx_shell" {
1308 let cmd = get_str(args, "command").unwrap_or_default();
1309 let output_tokens = crate::core::tokens::count_tokens(&result_text);
1310 let calls = self.tool_calls.read().await;
1311 let last_original = calls.last().map(|c| c.original_tokens).unwrap_or(0);
1312 drop(calls);
1313 if let Some(hint) = crate::tools::autonomy::shell_efficiency_hint(
1314 &self.autonomy,
1315 &cmd,
1316 last_original,
1317 output_tokens,
1318 ) {
1319 result_text = format!("{result_text}\n{hint}");
1320 }
1321 }
1322
1323 let skip_checkpoint = matches!(
1324 name,
1325 "ctx_compress"
1326 | "ctx_metrics"
1327 | "ctx_benchmark"
1328 | "ctx_analyze"
1329 | "ctx_cache"
1330 | "ctx_discover"
1331 | "ctx_dedup"
1332 | "ctx_session"
1333 | "ctx_knowledge"
1334 | "ctx_agent"
1335 | "ctx_wrapped"
1336 | "ctx_overview"
1337 | "ctx_preload"
1338 );
1339
1340 if !skip_checkpoint && self.increment_and_check() {
1341 if let Some(checkpoint) = self.auto_checkpoint().await {
1342 let combined = format!(
1343 "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
1344 self.checkpoint_interval
1345 );
1346 return Ok(CallToolResult::success(vec![Content::text(combined)]));
1347 }
1348 }
1349
1350 let tool_duration_ms = tool_start.elapsed().as_millis() as u64;
1351 if tool_duration_ms > 100 {
1352 LeanCtxServer::append_tool_call_log(
1353 name,
1354 tool_duration_ms,
1355 0,
1356 0,
1357 None,
1358 &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
1359 );
1360 }
1361
1362 let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1363 if current_count > 0 && current_count.is_multiple_of(100) {
1364 std::thread::spawn(cloud_background_tasks);
1365 }
1366
1367 Ok(CallToolResult::success(vec![Content::text(result_text)]))
1368 }
1369}
1370
1371fn build_instructions(crp_mode: CrpMode) -> String {
1372 build_instructions_with_client(crp_mode, "")
1373}
1374
1375fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
1376 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
1377 let session_block = match crate::core::session::SessionState::load_latest() {
1378 Some(ref session) => {
1379 let positioned = crate::core::litm::position_optimize(session);
1380 format!(
1381 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
1382 profile.name, positioned.begin_block
1383 )
1384 }
1385 None => String::new(),
1386 };
1387
1388 let knowledge_block = {
1389 let project_root = crate::core::session::SessionState::load_latest()
1390 .and_then(|s| s.project_root)
1391 .or_else(|| {
1392 std::env::current_dir()
1393 .ok()
1394 .map(|p| p.to_string_lossy().to_string())
1395 });
1396 match project_root {
1397 Some(root) => {
1398 let knowledge = crate::core::knowledge::ProjectKnowledge::load(&root);
1399 match knowledge {
1400 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
1401 format!("\n--- PROJECT KNOWLEDGE ---\n{}\n---\n", k.format_summary())
1402 }
1403 _ => String::new(),
1404 }
1405 }
1406 None => String::new(),
1407 }
1408 };
1409
1410 let mut base = format!("\
1413PREFER lean-ctx tools over native equivalents for token savings:\n\
1414\n\
1415lean-ctx MCP — tool mapping:\n\
1416• Read/cat/head/tail -> ctx_read(path, mode)\n\
1417• Shell/bash -> ctx_shell(command)\n\
1418• Grep/rg -> ctx_search(pattern, path)\n\
1419• ls/find -> ctx_tree(path, depth)\n\
1420• Edit/StrReplace -> use native if available, otherwise use ctx_edit(path, old_string, new_string)\n\
1421• Write, Delete, Glob -> use normally\n\
1422\n\
1423FILE EDITING: Use your IDE's native Edit/StrReplace when available. \
1424If Edit requires native Read and Read is unavailable, use ctx_edit instead — it reads, replaces, and writes in one call. \
1425NEVER loop trying to make Edit work. If Edit fails, switch to ctx_edit immediately.\n\
1426\n\
1427ctx_read modes: full (cached, for edits), map (deps+API), signatures, diff, task (IB-filtered), \
1428reference, aggressive, entropy, lines:N-M. Auto-selects when unspecified. Re-reads ~13 tokens. File refs F1,F2.. persist.\n\
1429If ctx_read returns 'cached': use fresh=true, start_line=N, or mode='lines:N-M' to re-read.\n\
1430\n\
1431AUTONOMY: lean-ctx auto-runs ctx_overview, ctx_preload, ctx_dedup, ctx_compress behind the scenes.\n\
1432Focus on: ctx_read, ctx_shell, ctx_search, ctx_tree. Use ctx_session for memory, ctx_knowledge for project facts.\n\
1433ctx_shell raw=true: skip compression for small/critical outputs. Full output tee files at ~/.lean-ctx/tee/.\n\
1434\n\
1435Auto-checkpoint every 15 calls. Cache clears after 5 min idle.\n\
1436\n\
1437CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) 4.ONE LINE PER ACTION 5.QUALITY ANCHOR\n\
1438\n\
1439{decoder_block}\n\
1440\n\
1441{session_block}\
1442{knowledge_block}\
1443\n\
1444--- TOOL PREFERENCE (LITM-END) ---\n\
1445Prefer: ctx_read over Read | ctx_shell over Shell | ctx_search over Grep | ctx_tree over ls\n\
1446Edit files: native Edit/StrReplace if available, ctx_edit if Edit requires unavailable Read.\n\
1447Write, Delete, Glob -> use normally. NEVER loop on Edit failures — use ctx_edit.",
1448 decoder_block = crate::core::protocol::instruction_decoder_block()
1449 );
1450
1451 if should_use_unified(client_name) {
1452 base.push_str(
1453 "\n\n\
1454UNIFIED TOOL MODE (active):\n\
1455Additional tools are accessed via ctx() meta-tool: ctx(tool=\"<name>\", ...params).\n\
1456See the ctx() tool description for available sub-tools.\n",
1457 );
1458 }
1459
1460 let intelligence_block = build_intelligence_block();
1461
1462 let base = base;
1463 match crp_mode {
1464 CrpMode::Off => format!("{base}\n\n{intelligence_block}"),
1465 CrpMode::Compact => {
1466 format!(
1467 "{base}\n\n\
1468CRP MODE: compact\n\
1469Compact Response Protocol:\n\
1470• Omit filler words, articles, redundant phrases\n\
1471• Abbreviate: fn, cfg, impl, deps, req, res, ctx, err, ret, arg, val, ty, mod\n\
1472• Compact lists over prose, code blocks over explanations\n\
1473• Code changes: diff lines (+/-) only, not full files\n\
1474• TARGET: <=200 tokens per response unless code edits require more\n\
1475• Tool outputs are pre-analyzed and compressed. Trust them directly.\n\n\
1476{intelligence_block}"
1477 )
1478 }
1479 CrpMode::Tdd => {
1480 format!(
1481 "{base}\n\n\
1482CRP MODE: tdd (Token Dense Dialect)\n\
1483Maximize information density. Every token must carry meaning.\n\
1484\n\
1485RESPONSE RULES:\n\
1486• Drop articles, filler words, pleasantries\n\
1487• Reference files by Fn refs only, never full paths\n\
1488• Code changes: diff lines only (+/-), not full files\n\
1489• No explanations unless asked\n\
1490• Tables for structured data\n\
1491• Abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret, arg, val, ty, mod\n\
1492\n\
1493CHANGE NOTATION:\n\
1494+F1:42 param(timeout:Duration) — added\n\
1495-F1:10-15 — removed\n\
1496~F1:42 validate_token -> verify_jwt — changed\n\
1497\n\
1498STATUS: ctx_read(F1) -> 808L cached ok | cargo test -> 82 passed 0 failed\n\
1499\n\
1500TOKEN BUDGET: <=150 tokens per response. Exceed only for multi-file edits.\n\
1501Tool outputs are pre-analyzed and compressed. Trust them directly.\n\
1502ZERO NARRATION: Act, then report result in 1 line.\n\n\
1503{intelligence_block}"
1504 )
1505 }
1506 }
1507}
1508
1509fn build_intelligence_block() -> String {
1510 "\
1511OUTPUT EFFICIENCY:\n\
1512• NEVER echo back code that was provided in tool outputs — it wastes tokens.\n\
1513• NEVER add narration comments (// Import, // Define, // Return) — code is self-documenting.\n\
1514• For code changes: show only the new/changed code, not unchanged context.\n\
1515• Tool outputs include [TASK:type] and SCOPE hints for context.\n\
1516• Respect the user's intent: architecture tasks need thorough analysis, simple generates need code."
1517 .to_string()
1518}
1519
1520fn tool_def(name: &'static str, description: &'static str, schema_value: Value) -> Tool {
1521 let schema: Map<String, Value> = match schema_value {
1522 Value::Object(map) => map,
1523 _ => Map::new(),
1524 };
1525 Tool::new(name, description, Arc::new(schema))
1526}
1527
1528fn unified_tool_defs() -> Vec<Tool> {
1529 vec![
1530 tool_def(
1531 "ctx_read",
1532 "Read file (cached, compressed). Modes: full|map|signatures|diff|aggressive|entropy|task|reference|lines:N-M. fresh=true re-reads.",
1533 json!({
1534 "type": "object",
1535 "properties": {
1536 "path": { "type": "string", "description": "File path" },
1537 "mode": { "type": "string" },
1538 "start_line": { "type": "integer" },
1539 "fresh": { "type": "boolean" }
1540 },
1541 "required": ["path"]
1542 }),
1543 ),
1544 tool_def(
1545 "ctx_shell",
1546 "Run shell command (compressed output). raw=true skips compression.",
1547 json!({
1548 "type": "object",
1549 "properties": {
1550 "command": { "type": "string", "description": "Shell command" },
1551 "raw": { "type": "boolean", "description": "Skip compression for full output" }
1552 },
1553 "required": ["command"]
1554 }),
1555 ),
1556 tool_def(
1557 "ctx_search",
1558 "Regex code search (.gitignore aware).",
1559 json!({
1560 "type": "object",
1561 "properties": {
1562 "pattern": { "type": "string", "description": "Regex pattern" },
1563 "path": { "type": "string" },
1564 "ext": { "type": "string" },
1565 "max_results": { "type": "integer" },
1566 "ignore_gitignore": { "type": "boolean" }
1567 },
1568 "required": ["pattern"]
1569 }),
1570 ),
1571 tool_def(
1572 "ctx_tree",
1573 "Directory listing with file counts.",
1574 json!({
1575 "type": "object",
1576 "properties": {
1577 "path": { "type": "string" },
1578 "depth": { "type": "integer" },
1579 "show_hidden": { "type": "boolean" }
1580 }
1581 }),
1582 ),
1583 tool_def(
1584 "ctx",
1585 "Meta-tool: set tool= to sub-tool name. Sub-tools: compress (checkpoint), metrics (stats), \
1586analyze (entropy), cache (status|clear|invalidate), discover (missed patterns), smart_read (auto-mode), \
1587delta (incremental diff), dedup (cross-file), fill (budget-aware batch read), intent (auto-read by task), \
1588response (compress LLM text), context (session state), graph (build|related|symbol|impact|status), \
1589session (load|save|task|finding|decision|status|reset|list|cleanup), \
1590knowledge (remember|recall|pattern|consolidate|status|remove|export), \
1591agent (register|post|read|status|list|info), overview (project map), \
1592wrapped (savings report), benchmark (file|project), multi_read (batch), semantic_search (BM25).",
1593 json!({
1594 "type": "object",
1595 "properties": {
1596 "tool": {
1597 "type": "string",
1598 "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"
1599 },
1600 "action": { "type": "string" },
1601 "path": { "type": "string" },
1602 "paths": { "type": "array", "items": { "type": "string" } },
1603 "query": { "type": "string" },
1604 "value": { "type": "string" },
1605 "category": { "type": "string" },
1606 "key": { "type": "string" },
1607 "budget": { "type": "integer" },
1608 "task": { "type": "string" },
1609 "mode": { "type": "string" },
1610 "text": { "type": "string" },
1611 "message": { "type": "string" },
1612 "session_id": { "type": "string" },
1613 "period": { "type": "string" },
1614 "format": { "type": "string" },
1615 "agent_type": { "type": "string" },
1616 "role": { "type": "string" },
1617 "status": { "type": "string" },
1618 "pattern_type": { "type": "string" },
1619 "examples": { "type": "array", "items": { "type": "string" } },
1620 "confidence": { "type": "number" },
1621 "project_root": { "type": "string" },
1622 "include_signatures": { "type": "boolean" },
1623 "limit": { "type": "integer" },
1624 "to_agent": { "type": "string" },
1625 "show_hidden": { "type": "boolean" }
1626 },
1627 "required": ["tool"]
1628 }),
1629 ),
1630 ]
1631}
1632
1633fn should_use_unified(client_name: &str) -> bool {
1634 if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
1635 return false;
1636 }
1637 if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
1638 return true;
1639 }
1640 let _ = client_name;
1641 false
1642}
1643
1644fn get_str_array(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<Vec<String>> {
1645 let arr = args.as_ref()?.get(key)?.as_array()?;
1646 let mut out = Vec::with_capacity(arr.len());
1647 for v in arr {
1648 let s = v.as_str()?.to_string();
1649 out.push(s);
1650 }
1651 Some(out)
1652}
1653
1654fn get_str(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<String> {
1655 args.as_ref()?.get(key)?.as_str().map(|s| s.to_string())
1656}
1657
1658fn get_int(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<i64> {
1659 args.as_ref()?.get(key)?.as_i64()
1660}
1661
1662fn get_bool(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<bool> {
1663 args.as_ref()?.get(key)?.as_bool()
1664}
1665
1666fn execute_command(command: &str) -> String {
1667 let (shell, flag) = crate::shell::shell_and_flag();
1668 let output = std::process::Command::new(&shell)
1669 .arg(&flag)
1670 .arg(command)
1671 .env("LEAN_CTX_ACTIVE", "1")
1672 .output();
1673
1674 match output {
1675 Ok(out) => {
1676 let stdout = String::from_utf8_lossy(&out.stdout);
1677 let stderr = String::from_utf8_lossy(&out.stderr);
1678 if stdout.is_empty() {
1679 stderr.to_string()
1680 } else if stderr.is_empty() {
1681 stdout.to_string()
1682 } else {
1683 format!("{stdout}\n{stderr}")
1684 }
1685 }
1686 Err(e) => format!("ERROR: {e}"),
1687 }
1688}
1689
1690fn detect_project_root(file_path: &str) -> Option<String> {
1691 let mut dir = std::path::Path::new(file_path).parent()?;
1692 loop {
1693 if dir.join(".git").exists() {
1694 return Some(dir.to_string_lossy().to_string());
1695 }
1696 dir = dir.parent()?;
1697 }
1698}
1699
1700fn cloud_background_tasks() {
1701 use crate::core::config::Config;
1702
1703 let mut config = Config::load();
1704 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1705
1706 let already_contributed = config
1707 .cloud
1708 .last_contribute
1709 .as_deref()
1710 .map(|d| d == today)
1711 .unwrap_or(false);
1712 let already_synced = config
1713 .cloud
1714 .last_sync
1715 .as_deref()
1716 .map(|d| d == today)
1717 .unwrap_or(false);
1718 let already_pulled = config
1719 .cloud
1720 .last_model_pull
1721 .as_deref()
1722 .map(|d| d == today)
1723 .unwrap_or(false);
1724
1725 if config.cloud.contribute_enabled && !already_contributed {
1726 if let Some(home) = dirs::home_dir() {
1727 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
1728 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
1729 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
1730 let mut entries = Vec::new();
1731 if let Some(history) = predictor["history"].as_object() {
1732 for (_key, outcomes) in history {
1733 if let Some(arr) = outcomes.as_array() {
1734 for outcome in arr.iter().rev().take(3) {
1735 let ext = outcome["ext"].as_str().unwrap_or("unknown");
1736 let mode = outcome["mode"].as_str().unwrap_or("full");
1737 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
1738 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
1739 let ratio = if t_in > 0 {
1740 1.0 - t_out as f64 / t_in as f64
1741 } else {
1742 0.0
1743 };
1744 let bucket = match t_in {
1745 0..=500 => "0-500",
1746 501..=2000 => "500-2k",
1747 2001..=10000 => "2k-10k",
1748 _ => "10k+",
1749 };
1750 entries.push(serde_json::json!({
1751 "file_ext": format!(".{ext}"),
1752 "size_bucket": bucket,
1753 "best_mode": mode,
1754 "compression_ratio": (ratio * 100.0).round() / 100.0,
1755 }));
1756 if entries.len() >= 200 {
1757 break;
1758 }
1759 }
1760 }
1761 if entries.len() >= 200 {
1762 break;
1763 }
1764 }
1765 }
1766 if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
1767 config.cloud.last_contribute = Some(today.clone());
1768 }
1769 }
1770 }
1771 }
1772 }
1773
1774 if crate::cloud_client::check_pro() {
1775 if !already_synced {
1776 let stats_data = crate::core::stats::format_gain_json();
1777 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
1778 let entry = serde_json::json!({
1779 "date": &today,
1780 "tokens_original": parsed["total_original_tokens"].as_i64().unwrap_or(0),
1781 "tokens_compressed": parsed["total_compressed_tokens"].as_i64().unwrap_or(0),
1782 "tokens_saved": parsed["total_saved_tokens"].as_i64().unwrap_or(0),
1783 "tool_calls": parsed["total_calls"].as_i64().unwrap_or(0),
1784 "cache_hits": parsed["cache_hits"].as_i64().unwrap_or(0),
1785 "cache_misses": parsed["cache_misses"].as_i64().unwrap_or(0),
1786 });
1787 if crate::cloud_client::sync_stats(&[entry]).is_ok() {
1788 config.cloud.last_sync = Some(today.clone());
1789 }
1790 }
1791 }
1792
1793 if !already_pulled {
1794 if let Ok(data) = crate::cloud_client::pull_pro_models() {
1795 let _ = crate::cloud_client::save_pro_models(&data);
1796 config.cloud.last_model_pull = Some(today.clone());
1797 }
1798 }
1799 }
1800
1801 let _ = config.save();
1802}
1803
1804pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1805 build_instructions(crp_mode)
1806}
1807
1808pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1809 let mut result = Vec::new();
1810 let tools_json = list_all_tool_defs();
1811 for (name, desc, _) in tools_json {
1812 result.push((name, desc));
1813 }
1814 result
1815}
1816
1817pub fn tool_schemas_json_for_test() -> String {
1818 let tools_json = list_all_tool_defs();
1819 let schemas: Vec<String> = tools_json
1820 .iter()
1821 .map(|(name, _, schema)| format!("{}: {}", name, schema))
1822 .collect();
1823 schemas.join("\n")
1824}
1825
1826fn list_all_tool_defs() -> Vec<(&'static str, &'static str, Value)> {
1827 vec![
1828 ("ctx_read", "Read file (cached, compressed). Re-reads ~13 tok. Auto-selects optimal mode. \
1829Modes: full|map|signatures|diff|aggressive|entropy|task|reference|lines:N-M. fresh=true re-reads.", json!({"type": "object", "properties": {"path": {"type": "string"}, "mode": {"type": "string"}, "start_line": {"type": "integer"}, "fresh": {"type": "boolean"}}, "required": ["path"]})),
1830 ("ctx_multi_read", "Batch read files in one call. Same modes as ctx_read.", json!({"type": "object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "mode": {"type": "string"}}, "required": ["paths"]})),
1831 ("ctx_tree", "Directory listing with file counts.", json!({"type": "object", "properties": {"path": {"type": "string"}, "depth": {"type": "integer"}, "show_hidden": {"type": "boolean"}}})),
1832 ("ctx_shell", "Run shell command (compressed output, 90+ patterns).", json!({"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]})),
1833 ("ctx_search", "Regex code search (.gitignore aware, compact results).", json!({"type": "object", "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}, "ext": {"type": "string"}, "max_results": {"type": "integer"}}, "required": ["pattern"]})),
1834 ("ctx_compress", "Context checkpoint for long conversations.", json!({"type": "object", "properties": {"include_signatures": {"type": "boolean"}}})),
1835 ("ctx_benchmark", "Benchmark compression modes for a file or project.", json!({"type": "object", "properties": {"path": {"type": "string"}, "action": {"type": "string"}, "format": {"type": "string"}}, "required": ["path"]})),
1836 ("ctx_metrics", "Session token stats, cache rates, per-tool savings.", json!({"type": "object", "properties": {}})),
1837 ("ctx_analyze", "Entropy analysis — recommends optimal compression mode for a file.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1838 ("ctx_cache", "Cache ops: status|clear|invalidate.", json!({"type": "object", "properties": {"action": {"type": "string"}, "path": {"type": "string"}}, "required": ["action"]})),
1839 ("ctx_discover", "Find missed compression opportunities in shell history.", json!({"type": "object", "properties": {"limit": {"type": "integer"}}})),
1840 ("ctx_smart_read", "Auto-select optimal read mode for a file.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1841 ("ctx_delta", "Incremental diff — sends only changed lines since last read.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1842 ("ctx_edit", "Edit a file via search-and-replace. Works without native Read/Edit tools. Use when Edit requires Read but Read is unavailable.", json!({"type": "object", "properties": {"path": {"type": "string"}, "old_string": {"type": "string"}, "new_string": {"type": "string"}, "replace_all": {"type": "boolean"}, "create": {"type": "boolean"}}, "required": ["path", "new_string"]})),
1843 ("ctx_dedup", "Cross-file dedup: analyze or apply shared block references.", json!({"type": "object", "properties": {"action": {"type": "string"}}})),
1844 ("ctx_fill", "Budget-aware context fill — auto-selects compression per file within token limit.", json!({"type": "object", "properties": {"paths": {"type": "array", "items": {"type": "string"}}, "budget": {"type": "integer"}}, "required": ["paths", "budget"]})),
1845 ("ctx_intent", "Intent detection — auto-reads relevant files based on task description.", json!({"type": "object", "properties": {"query": {"type": "string"}, "project_root": {"type": "string"}}, "required": ["query"]})),
1846 ("ctx_response", "Compress LLM response text (remove filler, apply TDD).", json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]})),
1847 ("ctx_context", "Session context overview — cached files, seen files, session state.", json!({"type": "object", "properties": {}})),
1848 ("ctx_graph", "Code dependency graph. Actions: build (index project), related (find files connected to path), \
1849symbol (lookup definition/usages as file::name), impact (blast radius of changes to path), status (index stats).", json!({"type": "object", "properties": {"action": {"type": "string"}, "path": {"type": "string"}, "project_root": {"type": "string"}}, "required": ["action"]})),
1850 ("ctx_session", "Cross-session memory (CCP). Actions: load (restore previous session ~400 tok), \
1851save, status, task (set current task), finding (record discovery), decision (record choice), \
1852reset, list (show sessions), cleanup.", json!({"type": "object", "properties": {"action": {"type": "string"}, "value": {"type": "string"}, "session_id": {"type": "string"}}, "required": ["action"]})),
1853 ("ctx_knowledge", "Persistent project knowledge (survives sessions). Actions: remember (store fact with category+key+value), \
1854recall (search by query), pattern (record naming/structure pattern), consolidate (extract session findings into knowledge), \
1855status (list all), remove, export.", json!({"type": "object", "properties": {"action": {"type": "string"}, "category": {"type": "string"}, "key": {"type": "string"}, "value": {"type": "string"}, "query": {"type": "string"}}, "required": ["action"]})),
1856 ("ctx_agent", "Multi-agent coordination (shared message bus). Actions: register (join with agent_type+role), \
1857post (broadcast or direct message with category), read (poll messages), status (update state: active|idle|finished), \
1858list, info.", json!({"type": "object", "properties": {"action": {"type": "string"}, "agent_type": {"type": "string"}, "role": {"type": "string"}, "message": {"type": "string"}}, "required": ["action"]})),
1859 ("ctx_overview", "Task-relevant project map — use at session start.", json!({"type": "object", "properties": {"task": {"type": "string"}, "path": {"type": "string"}}})),
1860 ("ctx_preload", "Proactive context loader — reads and caches task-relevant files, returns compact L-curve-optimized summary with critical lines, imports, and signatures. Costs ~50-100 tokens instead of ~5000 for individual reads.", json!({"type": "object", "properties": {"task": {"type": "string", "description": "Task description (e.g. 'fix auth bug in validate_token')"}, "path": {"type": "string", "description": "Project root (default: .)"}}, "required": ["task"]})),
1861 ("ctx_wrapped", "Savings report card. Periods: week|month|all.", json!({"type": "object", "properties": {"period": {"type": "string"}}})),
1862 ("ctx_semantic_search", "BM25 code search by meaning. action=reindex to rebuild.", json!({"type": "object", "properties": {"query": {"type": "string"}, "path": {"type": "string"}, "top_k": {"type": "integer"}, "action": {"type": "string"}}, "required": ["query"]})),
1863 ]
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868 use super::*;
1869
1870 #[test]
1871 fn test_should_use_unified_defaults_to_false() {
1872 assert!(!should_use_unified("cursor"));
1873 assert!(!should_use_unified("claude-code"));
1874 assert!(!should_use_unified("windsurf"));
1875 assert!(!should_use_unified(""));
1876 assert!(!should_use_unified("some-unknown-client"));
1877 }
1878
1879 #[test]
1880 fn test_unified_tool_count() {
1881 let tools = unified_tool_defs();
1882 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1883 }
1884}