Skip to main content

lean_ctx/tools/
ctx_provider.rs

1use crate::core::consolidation;
2use crate::core::providers::config::GitLabConfig;
3use crate::core::providers::provider_trait::ProviderParams;
4use crate::core::providers::registry::global_registry;
5use crate::core::providers::{gitlab, ProviderResult};
6use crate::server::tool_trait::ToolContext;
7
8pub fn handle(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
9    let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
10
11    match action {
12        // -- Discovery --
13        "discover" => handle_discover(),
14
15        // -- Registry-based routing (provider_id + resource) --
16        "query" => handle_registry_query(args, ctx),
17
18        // -- MCP Bridge convenience actions --
19        "mcp_resources" => handle_mcp_resources(args),
20
21        // -- Legacy GitLab actions (backward-compatible) --
22        "gitlab_issues" => handle_gitlab_issues(args),
23        "gitlab_issue" => handle_gitlab_issue(args),
24        "gitlab_mrs" => handle_gitlab_mrs(args),
25        "gitlab_pipelines" => handle_gitlab_pipelines(args),
26
27        _ => {
28            let available =
29                "discover, query, mcp_resources, gitlab_issues, gitlab_issue, gitlab_mrs, gitlab_pipelines";
30            format!("Unknown action: {action}. Available: {available}")
31        }
32    }
33}
34
35// ---------------------------------------------------------------------------
36// Discovery
37// ---------------------------------------------------------------------------
38
39fn handle_discover() -> String {
40    crate::core::providers::init::init_builtin_providers();
41    let infos = global_registry().discover();
42    if infos.is_empty() {
43        return "No providers registered. Set GITHUB_TOKEN or GITLAB_TOKEN.".to_string();
44    }
45
46    let mut out = format!("Registered providers ({}):\n", infos.len());
47    for info in &infos {
48        let status = if info.available {
49            "ready"
50        } else {
51            "unavailable"
52        };
53        out.push_str(&format!(
54            "  {} ({}) [{}] actions: {}\n",
55            info.id,
56            info.display_name,
57            status,
58            info.actions.join(", "),
59        ));
60    }
61    out
62}
63
64// ---------------------------------------------------------------------------
65// MCP Bridge convenience: list resources from a specific MCP bridge
66// ---------------------------------------------------------------------------
67
68fn handle_mcp_resources(args: &serde_json::Map<String, serde_json::Value>) -> String {
69    crate::core::providers::init::init_builtin_providers();
70
71    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
72        let registry = global_registry();
73        let mcp_providers: Vec<_> = registry
74            .discover()
75            .into_iter()
76            .filter(|p| p.id.starts_with("mcp:"))
77            .collect();
78
79        if mcp_providers.is_empty() {
80            return "No MCP bridges configured. Add [providers.mcp_bridges] to config.toml."
81                .to_string();
82        }
83
84        let mut out = format!("Available MCP bridges ({}):\n", mcp_providers.len());
85        for p in &mcp_providers {
86            let status = if p.available { "ready" } else { "unavailable" };
87            out.push_str(&format!("  {} ({}) [{}]\n", p.id, p.display_name, status));
88        }
89        out.push_str("\nUse provider=\"mcp:<name>\" to list resources from a specific bridge.");
90        return out;
91    };
92
93    let provider_id = if provider_id.starts_with("mcp:") {
94        provider_id.to_string()
95    } else {
96        format!("mcp:{provider_id}")
97    };
98
99    let params = ProviderParams {
100        limit: args
101            .get("limit")
102            .and_then(serde_json::Value::as_u64)
103            .map(|n| n as usize),
104        ..Default::default()
105    };
106
107    match global_registry().execute(&provider_id, "resources", &params) {
108        Ok(result) => format_result(&result),
109        Err(e) => format!("Error: {e}"),
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Registry-based query (new unified interface)
115// ---------------------------------------------------------------------------
116
117fn handle_registry_query(
118    args: &serde_json::Map<String, serde_json::Value>,
119    ctx: &ToolContext,
120) -> String {
121    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
122        &ctx.project_root,
123    )));
124
125    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
126        return "Error: 'provider' is required for action=query".to_string();
127    };
128    let Some(resource) = args.get("resource").and_then(|v| v.as_str()) else {
129        return "Error: 'resource' is required for action=query".to_string();
130    };
131
132    let params = ProviderParams {
133        project: args
134            .get("project")
135            .and_then(|v| v.as_str())
136            .map(String::from),
137        state: args.get("state").and_then(|v| v.as_str()).map(String::from),
138        limit: args
139            .get("limit")
140            .and_then(serde_json::Value::as_u64)
141            .map(|n| n as usize),
142        query: args.get("query").and_then(|v| v.as_str()).map(String::from),
143        id: args.get("id").and_then(|v| v.as_str()).map(String::from),
144    };
145
146    let mode = args
147        .get("mode")
148        .and_then(|v| v.as_str())
149        .unwrap_or("compact");
150
151    match mode {
152        "chunks" => handle_registry_chunks(provider_id, resource, &params, ctx),
153        _ => handle_registry_compact(provider_id, resource, &params, ctx),
154    }
155}
156
157fn handle_registry_compact(
158    provider_id: &str,
159    resource: &str,
160    params: &ProviderParams,
161    ctx: &ToolContext,
162) -> String {
163    match global_registry().execute_as_chunks(provider_id, resource, params) {
164        Ok(chunks) => {
165            consolidate_to_session(&chunks, ctx);
166            let result = global_registry().execute(provider_id, resource, params);
167            match result {
168                Ok(r) => format_result(&r),
169                Err(_) => format_chunks_compact(&chunks, provider_id, resource),
170            }
171        }
172        Err(e) => format!("Error: {e}"),
173    }
174}
175
176fn handle_registry_chunks(
177    provider_id: &str,
178    resource: &str,
179    params: &ProviderParams,
180    ctx: &ToolContext,
181) -> String {
182    match global_registry().execute_as_chunks(provider_id, resource, params) {
183        Ok(chunks) => {
184            consolidate_to_session(&chunks, ctx);
185            let mut out = format!(
186                "{} content chunks from {provider_id}/{resource}:\n",
187                chunks.len()
188            );
189            for c in &chunks {
190                let refs = if c.references.is_empty() {
191                    String::new()
192                } else {
193                    format!(" refs:[{}]", c.references.join(","))
194                };
195                out.push_str(&format!(
196                    "  {} {:?} ({}tok){}\n",
197                    c.file_path, c.kind, c.token_count, refs
198                ));
199            }
200            out
201        }
202        Err(e) => format!("Error: {e}"),
203    }
204}
205
206/// Consolidate provider chunks into ALL long-term stores:
207///   1. Session cache (fast re-reads at ~13 tokens)
208///   2. BM25 index (searchable via ctx_semantic_search)
209///   3. Graph index (cross-source edges for ctx_read hints)
210///   4. Knowledge (extracted facts for ctx_knowledge)
211///
212/// Cache writes happen synchronously (fast). BM25/Graph/Knowledge
213/// writes happen in a background thread to avoid blocking the tool
214/// response — the "hippocampal sleep replay" pattern.
215fn consolidate_to_session(chunks: &[crate::core::content_chunk::ContentChunk], ctx: &ToolContext) {
216    if chunks.is_empty() {
217        return;
218    }
219
220    let artifacts = consolidation::consolidate(chunks);
221    if artifacts.is_empty() {
222        return;
223    }
224
225    // Phase 1: Session cache (synchronous, fast)
226    if let Some(cache_lock) = ctx.cache.as_ref() {
227        if let Ok(mut cache) = cache_lock.try_write() {
228            for entry in &artifacts.cache_entries {
229                cache.store(&entry.uri, &entry.content);
230            }
231        }
232    }
233
234    let external_count = artifacts
235        .bm25_chunks
236        .iter()
237        .filter(|c| c.is_external())
238        .count();
239    let edge_count = artifacts.edges.len();
240    let fact_count = artifacts.facts.len();
241    let cache_count = artifacts.cache_entries.len();
242
243    tracing::debug!(
244        "[ctx_provider] consolidated {} chunks → {} edges, {} facts, {} cached",
245        external_count,
246        edge_count,
247        fact_count,
248        cache_count,
249    );
250
251    // Phase 2: Deep indexing (background thread — BM25, Graph, Knowledge)
252    let cfg = crate::core::config::Config::load();
253    if !cfg.providers.auto_index {
254        return;
255    }
256
257    let project_root = ctx.project_root.clone();
258    std::thread::spawn(move || {
259        apply_artifacts_to_stores(&artifacts, &project_root);
260    });
261}
262
263/// Apply consolidation artifacts to BM25, Graph, and Knowledge stores.
264/// Called from a background thread after provider queries.
265pub fn apply_artifacts_to_stores(
266    artifacts: &consolidation::ConsolidationArtifacts,
267    project_root: &str,
268) {
269    let root_path = std::path::Path::new(project_root);
270
271    // BM25: load existing index, ingest provider chunks, save
272    if !artifacts.bm25_chunks.is_empty() {
273        let mut index = crate::core::bm25_index::BM25Index::load_or_build(root_path);
274        let ingested = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
275        if ingested > 0 {
276            if let Err(e) = index.save(root_path) {
277                tracing::warn!("[ctx_provider] BM25 save failed: {e}");
278            } else {
279                tracing::info!("[ctx_provider] indexed {ingested} provider chunks into BM25");
280            }
281        }
282    }
283
284    // Graph: load existing index, merge cross-source edges, save
285    if !artifacts.edges.is_empty() {
286        let mut graph = crate::core::graph_index::load_or_build(project_root);
287        let added =
288            crate::core::cross_source_edges::merge_edges(&mut graph.edges, artifacts.edges.clone());
289        if added > 0 {
290            if let Err(e) = graph.save() {
291                tracing::warn!("[ctx_provider] graph save failed: {e}");
292            } else {
293                tracing::info!("[ctx_provider] added {added} cross-source edges to graph");
294            }
295        }
296    }
297
298    // Knowledge: load or create, remember extracted facts, save
299    if !artifacts.facts.is_empty() {
300        let policy = crate::core::memory_policy::MemoryPolicy::default();
301        let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root)
302            .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(project_root));
303
304        let session_id = format!("provider-ingest-{}", chrono::Utc::now().timestamp());
305        for fact in &artifacts.facts {
306            knowledge.remember(
307                &fact.category,
308                &fact.key,
309                &fact.value,
310                &session_id,
311                fact.confidence,
312                &policy,
313            );
314        }
315
316        if let Err(e) = knowledge.save() {
317            tracing::warn!("[ctx_provider] knowledge save failed: {e}");
318        } else {
319            tracing::info!(
320                "[ctx_provider] remembered {} facts from provider data",
321                artifacts.facts.len()
322            );
323        }
324    }
325}
326
327fn format_chunks_compact(
328    chunks: &[crate::core::content_chunk::ContentChunk],
329    provider_id: &str,
330    resource: &str,
331) -> String {
332    let mut out = format!("{} results from {provider_id}/{resource}:\n", chunks.len());
333    for c in chunks {
334        out.push_str(&format!(
335            "  #{} {}\n",
336            c.file_path.rsplit('/').next().unwrap_or("?"),
337            c.symbol_name
338        ));
339    }
340    out
341}
342
343// ---------------------------------------------------------------------------
344// Legacy GitLab handlers (unchanged)
345// ---------------------------------------------------------------------------
346
347fn handle_gitlab_issues(args: &serde_json::Map<String, serde_json::Value>) -> String {
348    let config = match GitLabConfig::from_env() {
349        Ok(c) => c,
350        Err(e) => return format!("Error: {e}"),
351    };
352    let state = args.get("state").and_then(|v| v.as_str());
353    let labels = args.get("labels").and_then(|v| v.as_str());
354    let limit = args
355        .get("limit")
356        .and_then(serde_json::Value::as_u64)
357        .map(|n| n as usize);
358
359    match gitlab::list_issues(&config, state, labels, limit) {
360        Ok(result) => format_result(&result),
361        Err(e) => format!("Error: {e}"),
362    }
363}
364
365fn handle_gitlab_issue(args: &serde_json::Map<String, serde_json::Value>) -> String {
366    let config = match GitLabConfig::from_env() {
367        Ok(c) => c,
368        Err(e) => return format!("Error: {e}"),
369    };
370    let iid = args
371        .get("iid")
372        .and_then(serde_json::Value::as_u64)
373        .unwrap_or(0);
374    if iid == 0 {
375        return "Error: iid is required for gitlab_issue".to_string();
376    }
377
378    match gitlab::show_issue(&config, iid) {
379        Ok(result) => format_result(&result),
380        Err(e) => format!("Error: {e}"),
381    }
382}
383
384fn handle_gitlab_mrs(args: &serde_json::Map<String, serde_json::Value>) -> String {
385    let config = match GitLabConfig::from_env() {
386        Ok(c) => c,
387        Err(e) => return format!("Error: {e}"),
388    };
389    let state = args.get("state").and_then(|v| v.as_str());
390    let limit = args
391        .get("limit")
392        .and_then(serde_json::Value::as_u64)
393        .map(|n| n as usize);
394
395    match gitlab::list_mrs(&config, state, limit) {
396        Ok(result) => format_result(&result),
397        Err(e) => format!("Error: {e}"),
398    }
399}
400
401fn handle_gitlab_pipelines(args: &serde_json::Map<String, serde_json::Value>) -> String {
402    let config = match GitLabConfig::from_env() {
403        Ok(c) => c,
404        Err(e) => return format!("Error: {e}"),
405    };
406    let status = args.get("status").and_then(|v| v.as_str());
407    let limit = args
408        .get("limit")
409        .and_then(serde_json::Value::as_u64)
410        .map(|n| n as usize);
411
412    match gitlab::list_pipelines(&config, status, limit) {
413        Ok(result) => format_result(&result),
414        Err(e) => format!("Error: {e}"),
415    }
416}
417
418fn format_result(result: &ProviderResult) -> String {
419    crate::core::redaction::redact_text_if_enabled(&result.format_compact())
420}