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