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