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.14.0"))
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.14.0"))
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_preload",
462 "Proactive context loader — caches task-relevant files, returns L-curve-optimized summary (~50-100 tokens vs ~5000 for individual reads).",
463 json!({
464 "type": "object",
465 "properties": {
466 "task": {
467 "type": "string",
468 "description": "Task description (e.g. 'fix auth bug in validate_token')"
469 },
470 "path": {
471 "type": "string",
472 "description": "Project root (default: .)"
473 }
474 },
475 "required": ["task"]
476 }),
477 ),
478 tool_def(
479 "ctx_wrapped",
480 "Savings report card. Periods: week|month|all.",
481 json!({
482 "type": "object",
483 "properties": {
484 "period": {
485 "type": "string",
486 "enum": ["week", "month", "all"],
487 "description": "Report period (default: week)"
488 }
489 }
490 }),
491 ),
492 tool_def(
493 "ctx_semantic_search",
494 "BM25 code search by meaning. action=reindex to rebuild.",
495 json!({
496 "type": "object",
497 "properties": {
498 "query": { "type": "string", "description": "Natural language search query" },
499 "path": { "type": "string", "description": "Project root to search (default: .)" },
500 "top_k": { "type": "integer", "description": "Number of results (default: 10)" },
501 "action": { "type": "string", "description": "reindex to rebuild index" }
502 },
503 "required": ["query"]
504 }),
505 ),
506 ],
507 ..Default::default()
508 })
509 }
510
511 async fn call_tool(
512 &self,
513 request: CallToolRequestParams,
514 _context: RequestContext<RoleServer>,
515 ) -> Result<CallToolResult, ErrorData> {
516 self.check_idle_expiry().await;
517
518 let original_name = request.name.as_ref().to_string();
519 let (resolved_name, resolved_args) = if original_name == "ctx" {
520 let sub = request
521 .arguments
522 .as_ref()
523 .and_then(|a| a.get("tool"))
524 .and_then(|v| v.as_str())
525 .map(|s| s.to_string())
526 .ok_or_else(|| {
527 ErrorData::invalid_params("'tool' is required for ctx meta-tool", None)
528 })?;
529 let tool_name = if sub.starts_with("ctx_") {
530 sub
531 } else {
532 format!("ctx_{sub}")
533 };
534 let mut args = request.arguments.unwrap_or_default();
535 args.remove("tool");
536 (tool_name, Some(args))
537 } else {
538 (original_name, request.arguments)
539 };
540 let name = resolved_name.as_str();
541 let args = &resolved_args;
542
543 let result_text = match name {
544 "ctx_read" => {
545 let path = get_str(args, "path")
546 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
547 let current_task = {
548 let session = self.session.read().await;
549 session.task.as_ref().map(|t| t.description.clone())
550 };
551 let task_ref = current_task.as_deref();
552 let mut mode = match get_str(args, "mode") {
553 Some(m) => m,
554 None => {
555 let cache = self.cache.read().await;
556 crate::tools::ctx_smart_read::select_mode_with_task(&cache, &path, task_ref)
557 }
558 };
559 let fresh = get_bool(args, "fresh").unwrap_or(false);
560 let start_line = get_int(args, "start_line");
561 if let Some(sl) = start_line {
562 let sl = sl.max(1_i64);
563 mode = format!("lines:{sl}-999999");
564 }
565 let stale = self.is_prompt_cache_stale().await;
566 let effective_mode = LeanCtxServer::upgrade_mode_if_stale(&mode, stale).to_string();
567 let mut cache = self.cache.write().await;
568 let output = if fresh {
569 crate::tools::ctx_read::handle_fresh_with_task(
570 &mut cache,
571 &path,
572 &effective_mode,
573 self.crp_mode,
574 task_ref,
575 )
576 } else {
577 crate::tools::ctx_read::handle_with_task(
578 &mut cache,
579 &path,
580 &effective_mode,
581 self.crp_mode,
582 task_ref,
583 )
584 };
585 let stale_note = if effective_mode != mode {
586 format!("[cache stale, {mode}→{effective_mode}]\n")
587 } else {
588 String::new()
589 };
590 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
591 let output_tokens = crate::core::tokens::count_tokens(&output);
592 let saved = original.saturating_sub(output_tokens);
593 let is_cache_hit = output.contains(" cached ");
594 let output = format!("{stale_note}{output}");
595 let file_ref = cache.file_ref_map().get(&path).cloned();
596 drop(cache);
597 {
598 let mut session = self.session.write().await;
599 session.touch_file(&path, file_ref.as_deref(), &effective_mode, original);
600 if is_cache_hit {
601 session.record_cache_hit();
602 }
603 if session.project_root.is_none() {
604 if let Some(root) = detect_project_root(&path) {
605 session.project_root = Some(root.clone());
606 let mut current = self.agent_id.write().await;
607 if current.is_none() {
608 let mut registry =
609 crate::core::agents::AgentRegistry::load_or_create();
610 registry.cleanup_stale(24);
611 let id = registry.register("mcp", None, &root);
612 let _ = registry.save();
613 *current = Some(id);
614 }
615 }
616 }
617 }
618 self.record_call("ctx_read", original, saved, Some(mode.clone()))
619 .await;
620 {
621 let sig =
622 crate::core::mode_predictor::FileSignature::from_path(&path, original);
623 let density = if output_tokens > 0 {
624 original as f64 / output_tokens as f64
625 } else {
626 1.0
627 };
628 let outcome = crate::core::mode_predictor::ModeOutcome {
629 mode: mode.clone(),
630 tokens_in: original,
631 tokens_out: output_tokens,
632 density: density.min(1.0),
633 };
634 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
635 predictor.record(sig, outcome);
636 predictor.save();
637
638 let ext = std::path::Path::new(&path)
639 .extension()
640 .and_then(|e| e.to_str())
641 .unwrap_or("")
642 .to_string();
643 let thresholds = crate::core::adaptive_thresholds::thresholds_for_path(&path);
644 let cache = self.cache.read().await;
645 let stats = cache.get_stats();
646 let feedback_outcome = crate::core::feedback::CompressionOutcome {
647 session_id: format!("{}", std::process::id()),
648 language: ext,
649 entropy_threshold: thresholds.bpe_entropy,
650 jaccard_threshold: thresholds.jaccard,
651 total_turns: stats.total_reads as u32,
652 tokens_saved: saved as u64,
653 tokens_original: original as u64,
654 cache_hits: stats.cache_hits as u32,
655 total_reads: stats.total_reads as u32,
656 task_completed: true,
657 timestamp: chrono::Local::now().to_rfc3339(),
658 };
659 drop(cache);
660 let mut store = crate::core::feedback::FeedbackStore::load();
661 store.record_outcome(feedback_outcome);
662 }
663 output
664 }
665 "ctx_multi_read" => {
666 let paths = get_str_array(args, "paths")
667 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
668 let mode = get_str(args, "mode").unwrap_or_else(|| "full".to_string());
669 let mut cache = self.cache.write().await;
670 let output =
671 crate::tools::ctx_multi_read::handle(&mut cache, &paths, &mode, self.crp_mode);
672 let mut total_original: usize = 0;
673 for path in &paths {
674 total_original = total_original
675 .saturating_add(cache.get(path).map(|e| e.original_tokens).unwrap_or(0));
676 }
677 let tokens = crate::core::tokens::count_tokens(&output);
678 drop(cache);
679 self.record_call(
680 "ctx_multi_read",
681 total_original,
682 total_original.saturating_sub(tokens),
683 Some(mode),
684 )
685 .await;
686 output
687 }
688 "ctx_tree" => {
689 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
690 let depth = get_int(args, "depth").unwrap_or(3) as usize;
691 let show_hidden = get_bool(args, "show_hidden").unwrap_or(false);
692 let (result, original) = crate::tools::ctx_tree::handle(&path, depth, show_hidden);
693 let sent = crate::core::tokens::count_tokens(&result);
694 let saved = original.saturating_sub(sent);
695 self.record_call("ctx_tree", original, saved, None).await;
696 let savings_note = if saved > 0 {
697 format!("\n[saved {saved} tokens vs native ls]")
698 } else {
699 String::new()
700 };
701 format!("{result}{savings_note}")
702 }
703 "ctx_shell" => {
704 let command = get_str(args, "command")
705 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
706 let output = execute_command(&command);
707 let result = crate::tools::ctx_shell::handle(&command, &output, self.crp_mode);
708 let original = crate::core::tokens::count_tokens(&output);
709 let sent = crate::core::tokens::count_tokens(&result);
710 let saved = original.saturating_sub(sent);
711 self.record_call("ctx_shell", original, saved, None).await;
712 let savings_note = if saved > 0 {
713 format!("\n[saved {saved} tokens vs native Shell]")
714 } else {
715 String::new()
716 };
717 format!("{result}{savings_note}")
718 }
719 "ctx_search" => {
720 let pattern = get_str(args, "pattern")
721 .ok_or_else(|| ErrorData::invalid_params("pattern is required", None))?;
722 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
723 let ext = get_str(args, "ext");
724 let max = get_int(args, "max_results").unwrap_or(20) as usize;
725 let no_gitignore = get_bool(args, "ignore_gitignore").unwrap_or(false);
726 let (result, original) = crate::tools::ctx_search::handle(
727 &pattern,
728 &path,
729 ext.as_deref(),
730 max,
731 self.crp_mode,
732 !no_gitignore,
733 );
734 let sent = crate::core::tokens::count_tokens(&result);
735 let saved = original.saturating_sub(sent);
736 self.record_call("ctx_search", original, saved, None).await;
737 let savings_note = if saved > 0 {
738 format!("\n[saved {saved} tokens vs native Grep]")
739 } else {
740 String::new()
741 };
742 format!("{result}{savings_note}")
743 }
744 "ctx_compress" => {
745 let include_sigs = get_bool(args, "include_signatures").unwrap_or(true);
746 let cache = self.cache.read().await;
747 let result =
748 crate::tools::ctx_compress::handle(&cache, include_sigs, self.crp_mode);
749 drop(cache);
750 self.record_call("ctx_compress", 0, 0, None).await;
751 result
752 }
753 "ctx_benchmark" => {
754 let path = get_str(args, "path")
755 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
756 let action = get_str(args, "action").unwrap_or_default();
757 let result = if action == "project" {
758 let fmt = get_str(args, "format").unwrap_or_default();
759 let bench = crate::core::benchmark::run_project_benchmark(&path);
760 match fmt.as_str() {
761 "json" => crate::core::benchmark::format_json(&bench),
762 "markdown" | "md" => crate::core::benchmark::format_markdown(&bench),
763 _ => crate::core::benchmark::format_terminal(&bench),
764 }
765 } else {
766 crate::tools::ctx_benchmark::handle(&path, self.crp_mode)
767 };
768 self.record_call("ctx_benchmark", 0, 0, None).await;
769 result
770 }
771 "ctx_metrics" => {
772 let cache = self.cache.read().await;
773 let calls = self.tool_calls.read().await;
774 let result = crate::tools::ctx_metrics::handle(&cache, &calls, self.crp_mode);
775 drop(cache);
776 drop(calls);
777 self.record_call("ctx_metrics", 0, 0, None).await;
778 result
779 }
780 "ctx_analyze" => {
781 let path = get_str(args, "path")
782 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
783 let result = crate::tools::ctx_analyze::handle(&path, self.crp_mode);
784 self.record_call("ctx_analyze", 0, 0, None).await;
785 result
786 }
787 "ctx_discover" => {
788 let limit = get_int(args, "limit").unwrap_or(15) as usize;
789 let history = crate::cli::load_shell_history_pub();
790 let result = crate::tools::ctx_discover::discover_from_history(&history, limit);
791 self.record_call("ctx_discover", 0, 0, None).await;
792 result
793 }
794 "ctx_smart_read" => {
795 let path = get_str(args, "path")
796 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
797 let mut cache = self.cache.write().await;
798 let output = crate::tools::ctx_smart_read::handle(&mut cache, &path, self.crp_mode);
799 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
800 let tokens = crate::core::tokens::count_tokens(&output);
801 drop(cache);
802 self.record_call(
803 "ctx_smart_read",
804 original,
805 original.saturating_sub(tokens),
806 Some("auto".to_string()),
807 )
808 .await;
809 output
810 }
811 "ctx_delta" => {
812 let path = get_str(args, "path")
813 .ok_or_else(|| ErrorData::invalid_params("path is required", None))?;
814 let mut cache = self.cache.write().await;
815 let output = crate::tools::ctx_delta::handle(&mut cache, &path);
816 let original = cache.get(&path).map_or(0, |e| e.original_tokens);
817 let tokens = crate::core::tokens::count_tokens(&output);
818 drop(cache);
819 {
820 let mut session = self.session.write().await;
821 session.mark_modified(&path);
822 }
823 self.record_call(
824 "ctx_delta",
825 original,
826 original.saturating_sub(tokens),
827 Some("delta".to_string()),
828 )
829 .await;
830 output
831 }
832 "ctx_dedup" => {
833 let action = get_str(args, "action").unwrap_or_default();
834 if action == "apply" {
835 let mut cache = self.cache.write().await;
836 let result = crate::tools::ctx_dedup::handle_action(&mut cache, &action);
837 drop(cache);
838 self.record_call("ctx_dedup", 0, 0, None).await;
839 result
840 } else {
841 let cache = self.cache.read().await;
842 let result = crate::tools::ctx_dedup::handle(&cache);
843 drop(cache);
844 self.record_call("ctx_dedup", 0, 0, None).await;
845 result
846 }
847 }
848 "ctx_fill" => {
849 let paths = get_str_array(args, "paths")
850 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
851 let budget = get_int(args, "budget")
852 .ok_or_else(|| ErrorData::invalid_params("budget is required", None))?
853 as usize;
854 let mut cache = self.cache.write().await;
855 let output =
856 crate::tools::ctx_fill::handle(&mut cache, &paths, budget, self.crp_mode);
857 drop(cache);
858 self.record_call("ctx_fill", 0, 0, Some(format!("budget:{budget}")))
859 .await;
860 output
861 }
862 "ctx_intent" => {
863 let query = get_str(args, "query")
864 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
865 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
866 let mut cache = self.cache.write().await;
867 let output =
868 crate::tools::ctx_intent::handle(&mut cache, &query, &root, self.crp_mode);
869 drop(cache);
870 {
871 let mut session = self.session.write().await;
872 session.set_task(&query, Some("intent"));
873 }
874 self.record_call("ctx_intent", 0, 0, Some("semantic".to_string()))
875 .await;
876 output
877 }
878 "ctx_response" => {
879 let text = get_str(args, "text")
880 .ok_or_else(|| ErrorData::invalid_params("text is required", None))?;
881 let output = crate::tools::ctx_response::handle(&text, self.crp_mode);
882 self.record_call("ctx_response", 0, 0, None).await;
883 output
884 }
885 "ctx_context" => {
886 let cache = self.cache.read().await;
887 let turn = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
888 let result = crate::tools::ctx_context::handle_status(&cache, turn, self.crp_mode);
889 drop(cache);
890 self.record_call("ctx_context", 0, 0, None).await;
891 result
892 }
893 "ctx_graph" => {
894 let action = get_str(args, "action")
895 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
896 let path = get_str(args, "path");
897 let root = get_str(args, "project_root").unwrap_or_else(|| ".".to_string());
898 let mut cache = self.cache.write().await;
899 let result = crate::tools::ctx_graph::handle(
900 &action,
901 path.as_deref(),
902 &root,
903 &mut cache,
904 self.crp_mode,
905 );
906 drop(cache);
907 self.record_call("ctx_graph", 0, 0, Some(action)).await;
908 result
909 }
910 "ctx_cache" => {
911 let action = get_str(args, "action")
912 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
913 let mut cache = self.cache.write().await;
914 let result = match action.as_str() {
915 "status" => {
916 let entries = cache.get_all_entries();
917 if entries.is_empty() {
918 "Cache empty — no files tracked.".to_string()
919 } else {
920 let mut lines = vec![format!("Cache: {} file(s)", entries.len())];
921 for (path, entry) in &entries {
922 let fref = cache
923 .file_ref_map()
924 .get(*path)
925 .map(|s| s.as_str())
926 .unwrap_or("F?");
927 lines.push(format!(
928 " {fref}={} [{}L, {}t, read {}x]",
929 crate::core::protocol::shorten_path(path),
930 entry.line_count,
931 entry.original_tokens,
932 entry.read_count
933 ));
934 }
935 lines.join("\n")
936 }
937 }
938 "clear" => {
939 let count = cache.clear();
940 format!("Cache cleared — {count} file(s) removed. Next ctx_read will return full content.")
941 }
942 "invalidate" => {
943 let path = get_str(args, "path").ok_or_else(|| {
944 ErrorData::invalid_params("path is required for invalidate", None)
945 })?;
946 if cache.invalidate(&path) {
947 format!(
948 "Invalidated cache for {}. Next ctx_read will return full content.",
949 crate::core::protocol::shorten_path(&path)
950 )
951 } else {
952 format!(
953 "{} was not in cache.",
954 crate::core::protocol::shorten_path(&path)
955 )
956 }
957 }
958 _ => "Unknown action. Use: status, clear, invalidate".to_string(),
959 };
960 drop(cache);
961 self.record_call("ctx_cache", 0, 0, Some(action)).await;
962 result
963 }
964 "ctx_session" => {
965 let action = get_str(args, "action")
966 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
967 let value = get_str(args, "value");
968 let sid = get_str(args, "session_id");
969 let mut session = self.session.write().await;
970 let result = crate::tools::ctx_session::handle(
971 &mut session,
972 &action,
973 value.as_deref(),
974 sid.as_deref(),
975 );
976 drop(session);
977 self.record_call("ctx_session", 0, 0, Some(action)).await;
978 result
979 }
980 "ctx_knowledge" => {
981 let action = get_str(args, "action")
982 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
983 let category = get_str(args, "category");
984 let key = get_str(args, "key");
985 let value = get_str(args, "value");
986 let query = get_str(args, "query");
987 let pattern_type = get_str(args, "pattern_type");
988 let examples = get_str_array(args, "examples");
989 let confidence: Option<f32> = args
990 .as_ref()
991 .and_then(|a| a.get("confidence"))
992 .and_then(|v| v.as_f64())
993 .map(|v| v as f32);
994
995 let session = self.session.read().await;
996 let session_id = session.id.clone();
997 let project_root = session.project_root.clone().unwrap_or_else(|| {
998 std::env::current_dir()
999 .map(|p| p.to_string_lossy().to_string())
1000 .unwrap_or_else(|_| "unknown".to_string())
1001 });
1002 drop(session);
1003
1004 let result = crate::tools::ctx_knowledge::handle(
1005 &project_root,
1006 &action,
1007 category.as_deref(),
1008 key.as_deref(),
1009 value.as_deref(),
1010 query.as_deref(),
1011 &session_id,
1012 pattern_type.as_deref(),
1013 examples,
1014 confidence,
1015 );
1016 self.record_call("ctx_knowledge", 0, 0, Some(action)).await;
1017 result
1018 }
1019 "ctx_agent" => {
1020 let action = get_str(args, "action")
1021 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
1022 let agent_type = get_str(args, "agent_type");
1023 let role = get_str(args, "role");
1024 let message = get_str(args, "message");
1025 let category = get_str(args, "category");
1026 let to_agent = get_str(args, "to_agent");
1027 let status = get_str(args, "status");
1028
1029 let session = self.session.read().await;
1030 let project_root = session.project_root.clone().unwrap_or_else(|| {
1031 std::env::current_dir()
1032 .map(|p| p.to_string_lossy().to_string())
1033 .unwrap_or_else(|_| "unknown".to_string())
1034 });
1035 drop(session);
1036
1037 let current_agent_id = self.agent_id.read().await.clone();
1038 let result = crate::tools::ctx_agent::handle(
1039 &action,
1040 agent_type.as_deref(),
1041 role.as_deref(),
1042 &project_root,
1043 current_agent_id.as_deref(),
1044 message.as_deref(),
1045 category.as_deref(),
1046 to_agent.as_deref(),
1047 status.as_deref(),
1048 );
1049
1050 if action == "register" {
1051 if let Some(id) = result.split(':').nth(1) {
1052 let id = id.split_whitespace().next().unwrap_or("").to_string();
1053 if !id.is_empty() {
1054 *self.agent_id.write().await = Some(id);
1055 }
1056 }
1057 }
1058
1059 self.record_call("ctx_agent", 0, 0, Some(action)).await;
1060 result
1061 }
1062 "ctx_overview" => {
1063 let task = get_str(args, "task");
1064 let path = get_str(args, "path");
1065 let cache = self.cache.read().await;
1066 let result = crate::tools::ctx_overview::handle(
1067 &cache,
1068 task.as_deref(),
1069 path.as_deref(),
1070 self.crp_mode,
1071 );
1072 drop(cache);
1073 self.record_call("ctx_overview", 0, 0, Some("overview".to_string()))
1074 .await;
1075 result
1076 }
1077 "ctx_preload" => {
1078 let task = get_str(args, "task").unwrap_or_default();
1079 let path = get_str(args, "path");
1080 let mut cache = self.cache.write().await;
1081 let result = crate::tools::ctx_preload::handle(
1082 &mut cache,
1083 &task,
1084 path.as_deref(),
1085 self.crp_mode,
1086 );
1087 drop(cache);
1088 self.record_call("ctx_preload", 0, 0, Some("preload".to_string()))
1089 .await;
1090 result
1091 }
1092 "ctx_wrapped" => {
1093 let period = get_str(args, "period").unwrap_or_else(|| "week".to_string());
1094 let result = crate::tools::ctx_wrapped::handle(&period);
1095 self.record_call("ctx_wrapped", 0, 0, Some(period)).await;
1096 result
1097 }
1098 "ctx_semantic_search" => {
1099 let query = get_str(args, "query")
1100 .ok_or_else(|| ErrorData::invalid_params("query is required", None))?;
1101 let path = get_str(args, "path").unwrap_or_else(|| ".".to_string());
1102 let top_k = get_int(args, "top_k").unwrap_or(10) as usize;
1103 let action = get_str(args, "action").unwrap_or_default();
1104 let result = if action == "reindex" {
1105 crate::tools::ctx_semantic_search::handle_reindex(&path)
1106 } else {
1107 crate::tools::ctx_semantic_search::handle(&query, &path, top_k, self.crp_mode)
1108 };
1109 self.record_call("ctx_semantic_search", 0, 0, Some("semantic".to_string()))
1110 .await;
1111 result
1112 }
1113 _ => {
1114 return Err(ErrorData::invalid_params(
1115 format!("Unknown tool: {name}"),
1116 None,
1117 ));
1118 }
1119 };
1120
1121 let skip_checkpoint = matches!(
1122 name,
1123 "ctx_compress"
1124 | "ctx_metrics"
1125 | "ctx_benchmark"
1126 | "ctx_analyze"
1127 | "ctx_cache"
1128 | "ctx_discover"
1129 | "ctx_dedup"
1130 | "ctx_session"
1131 | "ctx_knowledge"
1132 | "ctx_agent"
1133 | "ctx_wrapped"
1134 | "ctx_overview"
1135 | "ctx_preload"
1136 );
1137
1138 if !skip_checkpoint && self.increment_and_check() {
1139 if let Some(checkpoint) = self.auto_checkpoint().await {
1140 let combined = format!(
1141 "{result_text}\n\n--- AUTO CHECKPOINT (every {} calls) ---\n{checkpoint}",
1142 self.checkpoint_interval
1143 );
1144 return Ok(CallToolResult::success(vec![Content::text(combined)]));
1145 }
1146 }
1147
1148 let current_count = self.call_count.load(std::sync::atomic::Ordering::Relaxed);
1149 if current_count > 0 && current_count.is_multiple_of(100) {
1150 std::thread::spawn(cloud_background_tasks);
1151 }
1152
1153 Ok(CallToolResult::success(vec![Content::text(result_text)]))
1154 }
1155}
1156
1157fn build_instructions(crp_mode: CrpMode) -> String {
1158 build_instructions_with_client(crp_mode, "")
1159}
1160
1161fn build_instructions_with_client(crp_mode: CrpMode, client_name: &str) -> String {
1162 let profile = crate::core::litm::LitmProfile::from_client_name(client_name);
1163 let session_block = match crate::core::session::SessionState::load_latest() {
1164 Some(ref session) => {
1165 let positioned = crate::core::litm::position_optimize(session);
1166 format!(
1167 "\n\n--- ACTIVE SESSION (LITM P1: begin position, profile: {}) ---\n{}\n---\n",
1168 profile.name, positioned.begin_block
1169 )
1170 }
1171 None => String::new(),
1172 };
1173
1174 let knowledge_block = {
1175 let project_root = crate::core::session::SessionState::load_latest()
1176 .and_then(|s| s.project_root)
1177 .or_else(|| {
1178 std::env::current_dir()
1179 .ok()
1180 .map(|p| p.to_string_lossy().to_string())
1181 });
1182 match project_root {
1183 Some(root) => {
1184 let knowledge = crate::core::knowledge::ProjectKnowledge::load(&root);
1185 match knowledge {
1186 Some(k) if !k.facts.is_empty() || !k.patterns.is_empty() => {
1187 format!("\n--- PROJECT KNOWLEDGE ---\n{}\n---\n", k.format_summary())
1188 }
1189 _ => String::new(),
1190 }
1191 }
1192 None => String::new(),
1193 }
1194 };
1195
1196 let mut base = format!("\
1199CRITICAL: NEVER use native Read, Shell, Grep, or ls tools. ALWAYS use ctx_read, ctx_shell, ctx_search, ctx_tree instead.\n\
1200\n\
1201lean-ctx MCP — tool mapping:\n\
1202• Read/cat/head/tail -> ctx_read(path, mode)\n\
1203• Shell/bash -> ctx_shell(command)\n\
1204• Grep/rg -> ctx_search(pattern, path)\n\
1205• ls/find -> ctx_tree(path, depth)\n\
1206• Write, StrReplace, Delete, Glob -> use normally (no replacement)\n\
1207\n\
1208ctx_read modes: full (cached, for edits), map (deps+API), signatures, diff, task (IB-filtered), \
1209reference, aggressive, entropy, lines:N-M. Auto-selects when unspecified. Re-reads ~13 tokens. File refs F1,F2.. persist.\n\
1210If ctx_read returns 'cached': use fresh=true, start_line=N, or mode='lines:N-M' to re-read.\n\
1211\n\
1212PROACTIVE: ctx_overview(task) at start | ctx_preload(task) for focused context | ctx_compress when context grows | ctx_session load on new chat\n\
1213\n\
1214OTHER TOOLS: ctx_session (memory), ctx_knowledge (project facts), ctx_agent (coordination), \
1215ctx_metrics, ctx_analyze, ctx_benchmark, ctx_cache, ctx_wrapped, ctx_compress\n\
1216\n\
1217Auto-checkpoint every 15 calls. Cache clears after 5 min idle.\n\
1218\n\
1219CEP v1: 1.ACT FIRST 2.DELTA ONLY (Fn refs) 3.STRUCTURED (+/-/~) 4.ONE LINE PER ACTION 5.QUALITY ANCHOR\n\
1220\n\
1221{decoder_block}\n\
1222\n\
1223{session_block}\
1224{knowledge_block}\
1225\n\
1226--- TOOL ENFORCEMENT (LITM-END) ---\n\
1227Read/cat/head/tail -> ctx_read | Shell/bash -> ctx_shell | Grep/rg -> ctx_search | ls/find -> ctx_tree\n\
1228Write, StrReplace, Delete, Glob -> use normally",
1229 decoder_block = crate::core::protocol::instruction_decoder_block()
1230 );
1231
1232 if should_use_unified(client_name) {
1233 base.push_str(
1234 "\n\n\
1235UNIFIED TOOL MODE (active):\n\
1236Additional tools are accessed via ctx() meta-tool: ctx(tool=\"<name>\", ...params).\n\
1237See the ctx() tool description for available sub-tools.\n",
1238 );
1239 }
1240
1241 let base = base;
1242 match crp_mode {
1243 CrpMode::Off => base,
1244 CrpMode::Compact => {
1245 format!(
1246 "{base}\n\n\
1247CRP MODE: compact\n\
1248Compact Response Protocol:\n\
1249• Omit filler words, articles, redundant phrases\n\
1250• Abbreviate: fn, cfg, impl, deps, req, res, ctx, err, ret, arg, val, ty, mod\n\
1251• Compact lists over prose, code blocks over explanations\n\
1252• Code changes: diff lines (+/-) only, not full files\n\
1253• TARGET: <=200 tokens per response unless code edits require more\n\
1254• THINK LESS: Tool outputs are pre-analyzed. Trust summaries directly."
1255 )
1256 }
1257 CrpMode::Tdd => {
1258 format!(
1259 "{base}\n\n\
1260CRP MODE: tdd (Token Dense Dialect)\n\
1261Maximize information density. Every token must carry meaning.\n\
1262\n\
1263RESPONSE RULES:\n\
1264• Drop articles, filler words, pleasantries\n\
1265• Reference files by Fn refs only, never full paths\n\
1266• Code changes: diff lines only (+/-), not full files\n\
1267• No explanations unless asked\n\
1268• Tables for structured data\n\
1269• Abbreviations: fn, cfg, impl, deps, req, res, ctx, err, ret, arg, val, ty, mod\n\
1270\n\
1271CHANGE NOTATION:\n\
1272+F1:42 param(timeout:Duration) — added\n\
1273-F1:10-15 — removed\n\
1274~F1:42 validate_token -> verify_jwt — changed\n\
1275\n\
1276STATUS: ctx_read(F1) -> 808L cached ok | cargo test -> 82 passed 0 failed\n\
1277\n\
1278TOKEN BUDGET: <=150 tokens per response. Exceed only for multi-file edits.\n\
1279THINK LESS: Tool outputs are pre-analyzed. Trust compressed outputs directly.\n\
1280ZERO NARRATION: Act, then report result in 1 line."
1281 )
1282 }
1283 }
1284}
1285
1286fn tool_def(name: &'static str, description: &'static str, schema_value: Value) -> Tool {
1287 let schema: Map<String, Value> = match schema_value {
1288 Value::Object(map) => map,
1289 _ => Map::new(),
1290 };
1291 Tool::new(name, description, Arc::new(schema))
1292}
1293
1294fn unified_tool_defs() -> Vec<Tool> {
1295 vec![
1296 tool_def(
1297 "ctx_read",
1298 "Read file (cached, compressed). Modes: full|map|signatures|diff|aggressive|entropy|task|reference|lines:N-M. fresh=true re-reads.",
1299 json!({
1300 "type": "object",
1301 "properties": {
1302 "path": { "type": "string", "description": "File path" },
1303 "mode": { "type": "string" },
1304 "start_line": { "type": "integer" },
1305 "fresh": { "type": "boolean" }
1306 },
1307 "required": ["path"]
1308 }),
1309 ),
1310 tool_def(
1311 "ctx_shell",
1312 "Run shell command (compressed output).",
1313 json!({
1314 "type": "object",
1315 "properties": {
1316 "command": { "type": "string", "description": "Shell command" }
1317 },
1318 "required": ["command"]
1319 }),
1320 ),
1321 tool_def(
1322 "ctx_search",
1323 "Regex code search (.gitignore aware).",
1324 json!({
1325 "type": "object",
1326 "properties": {
1327 "pattern": { "type": "string", "description": "Regex pattern" },
1328 "path": { "type": "string" },
1329 "ext": { "type": "string" },
1330 "max_results": { "type": "integer" },
1331 "ignore_gitignore": { "type": "boolean" }
1332 },
1333 "required": ["pattern"]
1334 }),
1335 ),
1336 tool_def(
1337 "ctx_tree",
1338 "Directory listing with file counts.",
1339 json!({
1340 "type": "object",
1341 "properties": {
1342 "path": { "type": "string" },
1343 "depth": { "type": "integer" },
1344 "show_hidden": { "type": "boolean" }
1345 }
1346 }),
1347 ),
1348 tool_def(
1349 "ctx",
1350 "Meta-tool: set tool= to sub-tool name. Sub-tools: compress (checkpoint), metrics (stats), \
1351analyze (entropy), cache (status|clear|invalidate), discover (missed patterns), smart_read (auto-mode), \
1352delta (incremental diff), dedup (cross-file), fill (budget-aware batch read), intent (auto-read by task), \
1353response (compress LLM text), context (session state), graph (build|related|symbol|impact|status), \
1354session (load|save|task|finding|decision|status|reset|list|cleanup), \
1355knowledge (remember|recall|pattern|consolidate|status|remove|export), \
1356agent (register|post|read|status|list|info), overview (project map), \
1357wrapped (savings report), benchmark (file|project), multi_read (batch), semantic_search (BM25).",
1358 json!({
1359 "type": "object",
1360 "properties": {
1361 "tool": {
1362 "type": "string",
1363 "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"
1364 },
1365 "action": { "type": "string" },
1366 "path": { "type": "string" },
1367 "paths": { "type": "array", "items": { "type": "string" } },
1368 "query": { "type": "string" },
1369 "value": { "type": "string" },
1370 "category": { "type": "string" },
1371 "key": { "type": "string" },
1372 "budget": { "type": "integer" },
1373 "task": { "type": "string" },
1374 "mode": { "type": "string" },
1375 "text": { "type": "string" },
1376 "message": { "type": "string" },
1377 "session_id": { "type": "string" },
1378 "period": { "type": "string" },
1379 "format": { "type": "string" },
1380 "agent_type": { "type": "string" },
1381 "role": { "type": "string" },
1382 "status": { "type": "string" },
1383 "pattern_type": { "type": "string" },
1384 "examples": { "type": "array", "items": { "type": "string" } },
1385 "confidence": { "type": "number" },
1386 "project_root": { "type": "string" },
1387 "include_signatures": { "type": "boolean" },
1388 "limit": { "type": "integer" },
1389 "to_agent": { "type": "string" },
1390 "show_hidden": { "type": "boolean" }
1391 },
1392 "required": ["tool"]
1393 }),
1394 ),
1395 ]
1396}
1397
1398fn should_use_unified(client_name: &str) -> bool {
1399 if std::env::var("LEAN_CTX_FULL_TOOLS").is_ok() {
1400 return false;
1401 }
1402 if std::env::var("LEAN_CTX_UNIFIED").is_ok() {
1403 return true;
1404 }
1405 let _ = client_name;
1406 false
1407}
1408
1409fn get_str_array(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<Vec<String>> {
1410 let arr = args.as_ref()?.get(key)?.as_array()?;
1411 let mut out = Vec::with_capacity(arr.len());
1412 for v in arr {
1413 let s = v.as_str()?.to_string();
1414 out.push(s);
1415 }
1416 Some(out)
1417}
1418
1419fn get_str(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<String> {
1420 args.as_ref()?.get(key)?.as_str().map(|s| s.to_string())
1421}
1422
1423fn get_int(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<i64> {
1424 args.as_ref()?.get(key)?.as_i64()
1425}
1426
1427fn get_bool(args: &Option<serde_json::Map<String, Value>>, key: &str) -> Option<bool> {
1428 args.as_ref()?.get(key)?.as_bool()
1429}
1430
1431fn execute_command(command: &str) -> String {
1432 let (shell, flag) = crate::shell::shell_and_flag();
1433 let output = std::process::Command::new(&shell)
1434 .arg(&flag)
1435 .arg(command)
1436 .env("LEAN_CTX_ACTIVE", "1")
1437 .output();
1438
1439 match output {
1440 Ok(out) => {
1441 let stdout = String::from_utf8_lossy(&out.stdout);
1442 let stderr = String::from_utf8_lossy(&out.stderr);
1443 if stdout.is_empty() {
1444 stderr.to_string()
1445 } else if stderr.is_empty() {
1446 stdout.to_string()
1447 } else {
1448 format!("{stdout}\n{stderr}")
1449 }
1450 }
1451 Err(e) => format!("ERROR: {e}"),
1452 }
1453}
1454
1455fn detect_project_root(file_path: &str) -> Option<String> {
1456 let mut dir = std::path::Path::new(file_path).parent()?;
1457 loop {
1458 if dir.join(".git").exists() {
1459 return Some(dir.to_string_lossy().to_string());
1460 }
1461 dir = dir.parent()?;
1462 }
1463}
1464
1465fn cloud_background_tasks() {
1466 use crate::core::config::Config;
1467
1468 let mut config = Config::load();
1469 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
1470
1471 let already_contributed = config
1472 .cloud
1473 .last_contribute
1474 .as_deref()
1475 .map(|d| d == today)
1476 .unwrap_or(false);
1477 let already_synced = config
1478 .cloud
1479 .last_sync
1480 .as_deref()
1481 .map(|d| d == today)
1482 .unwrap_or(false);
1483 let already_pulled = config
1484 .cloud
1485 .last_model_pull
1486 .as_deref()
1487 .map(|d| d == today)
1488 .unwrap_or(false);
1489
1490 if config.cloud.contribute_enabled && !already_contributed {
1491 if let Some(home) = dirs::home_dir() {
1492 let mode_stats_path = home.join(".lean-ctx").join("mode_stats.json");
1493 if let Ok(data) = std::fs::read_to_string(&mode_stats_path) {
1494 if let Ok(predictor) = serde_json::from_str::<serde_json::Value>(&data) {
1495 let mut entries = Vec::new();
1496 if let Some(history) = predictor["history"].as_object() {
1497 for (_key, outcomes) in history {
1498 if let Some(arr) = outcomes.as_array() {
1499 for outcome in arr.iter().rev().take(3) {
1500 let ext = outcome["ext"].as_str().unwrap_or("unknown");
1501 let mode = outcome["mode"].as_str().unwrap_or("full");
1502 let t_in = outcome["tokens_in"].as_u64().unwrap_or(0);
1503 let t_out = outcome["tokens_out"].as_u64().unwrap_or(0);
1504 let ratio = if t_in > 0 {
1505 1.0 - t_out as f64 / t_in as f64
1506 } else {
1507 0.0
1508 };
1509 let bucket = match t_in {
1510 0..=500 => "0-500",
1511 501..=2000 => "500-2k",
1512 2001..=10000 => "2k-10k",
1513 _ => "10k+",
1514 };
1515 entries.push(serde_json::json!({
1516 "file_ext": format!(".{ext}"),
1517 "size_bucket": bucket,
1518 "best_mode": mode,
1519 "compression_ratio": (ratio * 100.0).round() / 100.0,
1520 }));
1521 if entries.len() >= 200 {
1522 break;
1523 }
1524 }
1525 }
1526 if entries.len() >= 200 {
1527 break;
1528 }
1529 }
1530 }
1531 if !entries.is_empty() && crate::cloud_client::contribute(&entries).is_ok() {
1532 config.cloud.last_contribute = Some(today.clone());
1533 }
1534 }
1535 }
1536 }
1537 }
1538
1539 if crate::cloud_client::check_pro() {
1540 if !already_synced {
1541 let stats_data = crate::core::stats::format_gain_json();
1542 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&stats_data) {
1543 let entry = serde_json::json!({
1544 "date": &today,
1545 "tokens_original": parsed["total_original_tokens"].as_i64().unwrap_or(0),
1546 "tokens_compressed": parsed["total_compressed_tokens"].as_i64().unwrap_or(0),
1547 "tokens_saved": parsed["total_saved_tokens"].as_i64().unwrap_or(0),
1548 "tool_calls": parsed["total_calls"].as_i64().unwrap_or(0),
1549 "cache_hits": parsed["cache_hits"].as_i64().unwrap_or(0),
1550 "cache_misses": parsed["cache_misses"].as_i64().unwrap_or(0),
1551 });
1552 if crate::cloud_client::sync_stats(&[entry]).is_ok() {
1553 config.cloud.last_sync = Some(today.clone());
1554 }
1555 }
1556 }
1557
1558 if !already_pulled {
1559 if let Ok(data) = crate::cloud_client::pull_pro_models() {
1560 let _ = crate::cloud_client::save_pro_models(&data);
1561 config.cloud.last_model_pull = Some(today.clone());
1562 }
1563 }
1564 }
1565
1566 let _ = config.save();
1567}
1568
1569pub fn build_instructions_for_test(crp_mode: CrpMode) -> String {
1570 build_instructions(crp_mode)
1571}
1572
1573pub fn tool_descriptions_for_test() -> Vec<(&'static str, &'static str)> {
1574 let mut result = Vec::new();
1575 let tools_json = list_all_tool_defs();
1576 for (name, desc, _) in tools_json {
1577 result.push((name, desc));
1578 }
1579 result
1580}
1581
1582pub fn tool_schemas_json_for_test() -> String {
1583 let tools_json = list_all_tool_defs();
1584 let schemas: Vec<String> = tools_json
1585 .iter()
1586 .map(|(name, _, schema)| format!("{}: {}", name, schema))
1587 .collect();
1588 schemas.join("\n")
1589}
1590
1591fn list_all_tool_defs() -> Vec<(&'static str, &'static str, Value)> {
1592 vec![
1593 ("ctx_read", "Read file (cached, compressed). Re-reads ~13 tok. Auto-selects optimal mode. \
1594Modes: 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"]})),
1595 ("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"]})),
1596 ("ctx_tree", "Directory listing with file counts.", json!({"type": "object", "properties": {"path": {"type": "string"}, "depth": {"type": "integer"}, "show_hidden": {"type": "boolean"}}})),
1597 ("ctx_shell", "Run shell command (compressed output, 90+ patterns).", json!({"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]})),
1598 ("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"]})),
1599 ("ctx_compress", "Context checkpoint for long conversations.", json!({"type": "object", "properties": {"include_signatures": {"type": "boolean"}}})),
1600 ("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"]})),
1601 ("ctx_metrics", "Session token stats, cache rates, per-tool savings.", json!({"type": "object", "properties": {}})),
1602 ("ctx_analyze", "Entropy analysis — recommends optimal compression mode for a file.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1603 ("ctx_cache", "Cache ops: status|clear|invalidate.", json!({"type": "object", "properties": {"action": {"type": "string"}, "path": {"type": "string"}}, "required": ["action"]})),
1604 ("ctx_discover", "Find missed compression opportunities in shell history.", json!({"type": "object", "properties": {"limit": {"type": "integer"}}})),
1605 ("ctx_smart_read", "Auto-select optimal read mode for a file.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1606 ("ctx_delta", "Incremental diff — sends only changed lines since last read.", json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]})),
1607 ("ctx_dedup", "Cross-file dedup: analyze or apply shared block references.", json!({"type": "object", "properties": {"action": {"type": "string"}}})),
1608 ("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"]})),
1609 ("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"]})),
1610 ("ctx_response", "Compress LLM response text (remove filler, apply TDD).", json!({"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]})),
1611 ("ctx_context", "Session context overview — cached files, seen files, session state.", json!({"type": "object", "properties": {}})),
1612 ("ctx_graph", "Code dependency graph. Actions: build (index project), related (find files connected to path), \
1613symbol (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"]})),
1614 ("ctx_session", "Cross-session memory (CCP). Actions: load (restore previous session ~400 tok), \
1615save, status, task (set current task), finding (record discovery), decision (record choice), \
1616reset, list (show sessions), cleanup.", json!({"type": "object", "properties": {"action": {"type": "string"}, "value": {"type": "string"}, "session_id": {"type": "string"}}, "required": ["action"]})),
1617 ("ctx_knowledge", "Persistent project knowledge (survives sessions). Actions: remember (store fact with category+key+value), \
1618recall (search by query), pattern (record naming/structure pattern), consolidate (extract session findings into knowledge), \
1619status (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"]})),
1620 ("ctx_agent", "Multi-agent coordination (shared message bus). Actions: register (join with agent_type+role), \
1621post (broadcast or direct message with category), read (poll messages), status (update state: active|idle|finished), \
1622list, info.", json!({"type": "object", "properties": {"action": {"type": "string"}, "agent_type": {"type": "string"}, "role": {"type": "string"}, "message": {"type": "string"}}, "required": ["action"]})),
1623 ("ctx_overview", "Task-relevant project map — use at session start.", json!({"type": "object", "properties": {"task": {"type": "string"}, "path": {"type": "string"}}})),
1624 ("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"]})),
1625 ("ctx_wrapped", "Savings report card. Periods: week|month|all.", json!({"type": "object", "properties": {"period": {"type": "string"}}})),
1626 ("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"]})),
1627 ]
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633
1634 #[test]
1635 fn test_should_use_unified_defaults_to_false() {
1636 assert!(!should_use_unified("cursor"));
1637 assert!(!should_use_unified("claude-code"));
1638 assert!(!should_use_unified("windsurf"));
1639 assert!(!should_use_unified(""));
1640 assert!(!should_use_unified("some-unknown-client"));
1641 }
1642
1643 #[test]
1644 fn test_unified_tool_count() {
1645 let tools = unified_tool_defs();
1646 assert_eq!(tools.len(), 5, "Expected 5 unified tools");
1647 }
1648}