Skip to main content

lean_ctx/tools/
ctx_provider.rs

1use crate::core::consolidation;
2use crate::core::providers::cache as provider_cache;
3use crate::core::providers::config::GitLabConfig;
4use crate::core::providers::provider_trait::ProviderParams;
5use crate::core::providers::registry::global_registry;
6use crate::core::providers::{gitlab, ProviderResult};
7use crate::server::tool_trait::ToolContext;
8
9pub fn handle(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
10    let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
11
12    match action {
13        // -- Discovery & Management --
14        "discover" | "list" => handle_discover(ctx),
15        "status" => handle_status(ctx),
16        "refresh" => handle_refresh(args, ctx),
17        "configure" => handle_configure(args, ctx),
18
19        // -- Registry-based routing (provider_id + resource) --
20        "query" => handle_registry_query(args, ctx),
21
22        // -- MCP Bridge convenience actions --
23        "mcp_resources" => handle_mcp_resources(args, ctx),
24
25        // -- Legacy GitLab actions (backward-compatible) --
26        "gitlab_issues" => handle_gitlab_issues(args),
27        "gitlab_issue" => handle_gitlab_issue(args),
28        "gitlab_mrs" => handle_gitlab_mrs(args),
29        "gitlab_pipelines" => handle_gitlab_pipelines(args),
30
31        _ => {
32            let available = "discover, list, status, refresh, configure, query, mcp_resources, \
33                 gitlab_issues, gitlab_issue, gitlab_mrs, gitlab_pipelines";
34            format!("Unknown action: {action}. Available: {available}")
35        }
36    }
37}
38
39// ---------------------------------------------------------------------------
40// Discovery
41// ---------------------------------------------------------------------------
42
43fn handle_discover(ctx: &ToolContext) -> String {
44    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
45        &ctx.project_root,
46    )));
47    let infos = global_registry().discover();
48    if infos.is_empty() {
49        return "No providers registered. Set GITHUB_TOKEN or GITLAB_TOKEN.".to_string();
50    }
51
52    let mut out = format!("Registered providers ({}):\n", infos.len());
53    for info in &infos {
54        let status = if info.available {
55            "ready"
56        } else {
57            "unavailable"
58        };
59        out.push_str(&format!(
60            "  {} ({}) [{}] actions: {}\n",
61            info.id,
62            info.display_name,
63            status,
64            info.actions.join(", "),
65        ));
66    }
67    out
68}
69
70// ---------------------------------------------------------------------------
71// Status — provider health + cache metrics
72// ---------------------------------------------------------------------------
73
74fn handle_status(ctx: &ToolContext) -> String {
75    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
76        &ctx.project_root,
77    )));
78
79    let infos = global_registry().discover();
80    let metrics = provider_cache::cache_metrics();
81
82    let mut out = String::new();
83
84    // Provider health
85    out.push_str(&format!("Provider Status ({} registered):\n", infos.len()));
86    for info in &infos {
87        let status = if info.available { "✓" } else { "✗" };
88        let auth = if info.requires_auth { " (auth)" } else { "" };
89        out.push_str(&format!(
90            "  {status} {} — {} [ttl:{}s]{auth}\n",
91            info.id, info.display_name, info.cache_ttl_secs,
92        ));
93    }
94
95    // Cache metrics
96    out.push_str(&format!(
97        "\nCache: {} entries, {:.0}% hit rate ({} hits / {} misses)\n",
98        metrics.total_entries,
99        metrics.total_hit_rate() * 100.0,
100        metrics.total_hits,
101        metrics.total_misses,
102    ));
103
104    if !metrics.provider_stats.is_empty() {
105        out.push_str("Per-provider:\n");
106        for ps in &metrics.provider_stats {
107            let last = ps
108                .last_fetch
109                .and_then(|t| t.elapsed().ok())
110                .map_or_else(|| "never".into(), |d| format!("{}s ago", d.as_secs()));
111            out.push_str(&format!(
112                "  {} — {} cached, {:.0}% hit rate, last fetch: {}\n",
113                ps.provider_id,
114                ps.entry_count,
115                ps.hit_rate() * 100.0,
116                last,
117            ));
118        }
119    }
120
121    out
122}
123
124// ---------------------------------------------------------------------------
125// Refresh — invalidate cache + re-fetch + re-consolidate
126// ---------------------------------------------------------------------------
127
128fn handle_refresh(args: &serde_json::Map<String, serde_json::Value>, ctx: &ToolContext) -> String {
129    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
130        &ctx.project_root,
131    )));
132
133    let provider_id = args.get("provider").and_then(|v| v.as_str());
134    let resource = args.get("resource").and_then(|v| v.as_str());
135
136    // Invalidate cache
137    let invalidated = if let Some(pid) = provider_id {
138        let count = provider_cache::invalidate_provider(pid);
139        format!("Invalidated {count} cached entries for '{pid}'")
140    } else {
141        let count = provider_cache::invalidate_all();
142        format!("Invalidated {count} cached entries (all providers)")
143    };
144
145    let mut out = format!("{invalidated}\n");
146
147    // Re-fetch if provider + resource specified
148    if let (Some(pid), Some(res)) = (provider_id, resource) {
149        let params = ProviderParams {
150            state: args.get("state").and_then(|v| v.as_str()).map(String::from),
151            limit: args
152                .get("limit")
153                .and_then(serde_json::Value::as_u64)
154                .map(|n| n as usize),
155            ..Default::default()
156        };
157
158        match global_registry().execute_as_chunks(pid, res, &params) {
159            Ok(chunks) => {
160                consolidate_to_session(&chunks, ctx);
161                out.push_str(&format!(
162                    "Re-fetched {pid}/{res}: {} items, consolidated to BM25+Graph+Knowledge\n",
163                    chunks.len()
164                ));
165            }
166            Err(e) => out.push_str(&format!("Re-fetch failed: {e}\n")),
167        }
168    } else if let Some(pid) = provider_id {
169        // Refresh all actions for a single provider
170        let registry = global_registry();
171        if let Some(provider) = registry.get(pid) {
172            let mut total = 0;
173            for action in provider.supported_actions() {
174                let params = ProviderParams {
175                    limit: Some(20),
176                    ..Default::default()
177                };
178                match registry.execute_as_chunks(pid, action, &params) {
179                    Ok(chunks) => {
180                        consolidate_to_session(&chunks, ctx);
181                        total += chunks.len();
182                    }
183                    Err(e) => {
184                        tracing::debug!("[ctx_provider] refresh {pid}/{action} failed: {e}");
185                    }
186                }
187            }
188            out.push_str(&format!(
189                "Re-fetched all actions for '{pid}': {total} items consolidated\n"
190            ));
191        } else {
192            out.push_str(&format!("Provider '{pid}' not found\n"));
193        }
194    } else {
195        out.push_str("Specify provider= to also re-fetch data after cache invalidation\n");
196    }
197
198    out
199}
200
201// ---------------------------------------------------------------------------
202// Configure — show config paths + available config providers
203// ---------------------------------------------------------------------------
204
205fn handle_configure(
206    args: &serde_json::Map<String, serde_json::Value>,
207    ctx: &ToolContext,
208) -> String {
209    let sub = args
210        .get("resource")
211        .and_then(|v| v.as_str())
212        .unwrap_or("show");
213
214    match sub {
215        "paths" => {
216            let mut out = String::from("Provider config locations (checked in order):\n");
217            out.push_str("  Single-file (providers.toml):\n");
218            if let Some(config_dir) = dirs::config_dir() {
219                let p = config_dir.join("lean-ctx").join("providers.toml");
220                let exists = if p.exists() { " ✓" } else { "" };
221                out.push_str(&format!("    {}{exists}\n", p.display()));
222            }
223            if let Some(home) = dirs::home_dir() {
224                let p = home.join(".lean-ctx").join("providers.toml");
225                let exists = if p.exists() { " ✓" } else { "" };
226                out.push_str(&format!("    {}{exists}\n", p.display()));
227            }
228            let p = std::path::Path::new(&ctx.project_root)
229                .join(".lean-ctx")
230                .join("providers.toml");
231            let exists = if p.exists() { " ✓" } else { "" };
232            out.push_str(&format!("    {}{exists}\n", p.display()));
233
234            out.push_str("  Per-file (one provider per file):\n");
235            if let Some(config_dir) = dirs::config_dir() {
236                let p = config_dir.join("lean-ctx").join("providers");
237                let exists = if p.exists() { " ✓" } else { "" };
238                out.push_str(&format!("    {}/{exists}\n", p.display()));
239            }
240            let p = std::path::Path::new(&ctx.project_root)
241                .join(".lean-ctx")
242                .join("providers");
243            let exists = if p.exists() { " ✓" } else { "" };
244            out.push_str(&format!("    {}/{exists}\n", p.display()));
245
246            out.push_str("\nEnvironment variables:\n");
247            for (var, label) in [
248                ("GITHUB_TOKEN", "GitHub"),
249                ("GITLAB_TOKEN", "GitLab"),
250                ("JIRA_URL", "Jira"),
251                ("DATABASE_URL", "PostgreSQL"),
252            ] {
253                let set = if std::env::var(var).is_ok() {
254                    "✓ set"
255                } else {
256                    "✗ not set"
257                };
258                out.push_str(&format!("  {var} ({label}): {set}\n"));
259            }
260            out
261        }
262        "template" => String::from(
263            r#"# providers.toml — drop in ~/.config/lean-ctx/ or .lean-ctx/
264# Each [[providers]] entry registers a custom REST API as a context source.
265
266[[providers]]
267id = "linear"
268name = "Linear"
269base_url = "https://api.linear.app"
270cache_ttl_secs = 120
271
272[providers.auth]
273type = "bearer"
274token_env = "LINEAR_API_KEY"
275
276[providers.resources.issues]
277method = "POST"
278path = "/graphql"
279
280[providers.resources.issues.response]
281root = "data.issues.nodes"
282
283[providers.resources.issues.response.mapping]
284id = "id"
285title = "title"
286body = "description"
287state = "state.name"
288labels = "labels.nodes[].name"
289
290# --- Built-in providers (env vars only) ---
291# GitHub: set GITHUB_TOKEN
292# GitLab: set GITLAB_TOKEN
293# Jira:   set JIRA_URL + JIRA_EMAIL + JIRA_TOKEN
294# Postgres: set DATABASE_URL or PGDATABASE
295"#,
296        ),
297        _ => {
298            let cfg = crate::core::config::Config::load();
299            let mut out = String::from("Provider configuration:\n");
300            out.push_str(&format!("  enabled: {}\n", cfg.providers.enabled));
301            out.push_str(&format!("  auto_index: {}\n", cfg.providers.auto_index));
302            out.push_str(&format!(
303                "  github.enabled: {}\n",
304                cfg.providers.github.enabled
305            ));
306            out.push_str(&format!(
307                "  gitlab.enabled: {}\n",
308                cfg.providers.gitlab.enabled
309            ));
310
311            if !cfg.providers.mcp_bridges.is_empty() {
312                out.push_str(&format!(
313                    "  mcp_bridges: {} configured\n",
314                    cfg.providers.mcp_bridges.len()
315                ));
316            }
317
318            let discovered = crate::core::providers::config_provider::discovery::discover_configs(
319                Some(std::path::Path::new(&ctx.project_root)),
320            );
321            if !discovered.is_empty() {
322                out.push_str(&format!(
323                    "  config providers: {} discovered\n",
324                    discovered.len()
325                ));
326                for d in &discovered {
327                    out.push_str(&format!(
328                        "    {} — {}\n",
329                        d.config.id,
330                        d.source_path.display()
331                    ));
332                }
333            }
334
335            out.push_str(
336                "\nUse resource=\"paths\" to see config file locations.\n\
337                 Use resource=\"template\" to get a providers.toml template.\n",
338            );
339            out
340        }
341    }
342}
343
344// ---------------------------------------------------------------------------
345// MCP Bridge convenience: list resources from a specific MCP bridge
346// ---------------------------------------------------------------------------
347
348fn handle_mcp_resources(
349    args: &serde_json::Map<String, serde_json::Value>,
350    ctx: &ToolContext,
351) -> String {
352    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
353        &ctx.project_root,
354    )));
355
356    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
357        let registry = global_registry();
358        let mcp_providers: Vec<_> = registry
359            .discover()
360            .into_iter()
361            .filter(|p| p.id.starts_with("mcp:"))
362            .collect();
363
364        if mcp_providers.is_empty() {
365            return "No MCP bridges configured. Add [providers.mcp_bridges] to config.toml."
366                .to_string();
367        }
368
369        let mut out = format!("Available MCP bridges ({}):\n", mcp_providers.len());
370        for p in &mcp_providers {
371            let status = if p.available { "ready" } else { "unavailable" };
372            out.push_str(&format!("  {} ({}) [{}]\n", p.id, p.display_name, status));
373        }
374        out.push_str("\nUse provider=\"mcp:<name>\" to list resources from a specific bridge.");
375        return out;
376    };
377
378    let provider_id = if provider_id.starts_with("mcp:") {
379        provider_id.to_string()
380    } else {
381        format!("mcp:{provider_id}")
382    };
383
384    let params = ProviderParams {
385        limit: args
386            .get("limit")
387            .and_then(serde_json::Value::as_u64)
388            .map(|n| n as usize),
389        ..Default::default()
390    };
391
392    match global_registry().execute(&provider_id, "resources", &params) {
393        Ok(result) => format_result(&result),
394        Err(e) => format!("Error: {e}"),
395    }
396}
397
398// ---------------------------------------------------------------------------
399// Registry-based query (new unified interface)
400// ---------------------------------------------------------------------------
401
402fn handle_registry_query(
403    args: &serde_json::Map<String, serde_json::Value>,
404    ctx: &ToolContext,
405) -> String {
406    crate::core::providers::init::init_with_project_root(Some(std::path::Path::new(
407        &ctx.project_root,
408    )));
409
410    let Some(provider_id) = args.get("provider").and_then(|v| v.as_str()) else {
411        return "Error: 'provider' is required for action=query".to_string();
412    };
413    let Some(resource) = args.get("resource").and_then(|v| v.as_str()) else {
414        return "Error: 'resource' is required for action=query".to_string();
415    };
416
417    let params = ProviderParams {
418        project: args
419            .get("project")
420            .and_then(|v| v.as_str())
421            .map(String::from),
422        state: args.get("state").and_then(|v| v.as_str()).map(String::from),
423        limit: args
424            .get("limit")
425            .and_then(serde_json::Value::as_u64)
426            .map(|n| n as usize),
427        query: args.get("query").and_then(|v| v.as_str()).map(String::from),
428        id: args.get("id").and_then(|v| v.as_str()).map(String::from),
429    };
430
431    let mode = args
432        .get("mode")
433        .and_then(|v| v.as_str())
434        .unwrap_or("compact");
435
436    match mode {
437        "chunks" => handle_registry_chunks(provider_id, resource, &params, ctx),
438        _ => handle_registry_compact(provider_id, resource, &params, ctx),
439    }
440}
441
442fn handle_registry_compact(
443    provider_id: &str,
444    resource: &str,
445    params: &ProviderParams,
446    ctx: &ToolContext,
447) -> String {
448    match global_registry().execute_as_chunks(provider_id, resource, params) {
449        Ok(chunks) => {
450            consolidate_to_session(&chunks, ctx);
451            let result = global_registry().execute(provider_id, resource, params);
452            match result {
453                Ok(r) => format_result(&r),
454                Err(_) => format_chunks_compact(&chunks, provider_id, resource),
455            }
456        }
457        Err(e) => format!("Error: {e}"),
458    }
459}
460
461fn handle_registry_chunks(
462    provider_id: &str,
463    resource: &str,
464    params: &ProviderParams,
465    ctx: &ToolContext,
466) -> String {
467    match global_registry().execute_as_chunks(provider_id, resource, params) {
468        Ok(chunks) => {
469            consolidate_to_session(&chunks, ctx);
470            let mut out = format!(
471                "{} content chunks from {provider_id}/{resource}:\n",
472                chunks.len()
473            );
474            for c in &chunks {
475                let refs = if c.references.is_empty() {
476                    String::new()
477                } else {
478                    format!(" refs:[{}]", c.references.join(","))
479                };
480                out.push_str(&format!(
481                    "  {} {:?} ({}tok){}\n",
482                    c.file_path, c.kind, c.token_count, refs
483                ));
484            }
485            out
486        }
487        Err(e) => format!("Error: {e}"),
488    }
489}
490
491/// Consolidate provider chunks into ALL long-term stores:
492///   1. Session cache (fast re-reads at ~13 tokens)
493///   2. BM25 index (searchable via ctx_semantic_search)
494///   3. Graph index (cross-source edges for ctx_read hints)
495///   4. Knowledge (extracted facts for ctx_knowledge)
496///
497/// Cache writes happen synchronously (fast). BM25/Graph/Knowledge
498/// writes happen in a background thread to avoid blocking the tool
499/// response — the "hippocampal sleep replay" pattern.
500fn consolidate_to_session(chunks: &[crate::core::content_chunk::ContentChunk], ctx: &ToolContext) {
501    if chunks.is_empty() {
502        return;
503    }
504
505    let artifacts = consolidation::consolidate(chunks);
506    if artifacts.is_empty() {
507        return;
508    }
509
510    // Phase 1: Session cache (synchronous, fast)
511    if let Some(cache_lock) = ctx.cache.as_ref() {
512        if let Ok(mut cache) = cache_lock.try_write() {
513            for entry in &artifacts.cache_entries {
514                cache.store(&entry.uri, &entry.content);
515            }
516        }
517    }
518
519    let external_count = artifacts
520        .bm25_chunks
521        .iter()
522        .filter(|c| c.is_external())
523        .count();
524    let edge_count = artifacts.edges.len();
525    let fact_count = artifacts.facts.len();
526    let cache_count = artifacts.cache_entries.len();
527
528    tracing::debug!(
529        "[ctx_provider] consolidated {} chunks → {} edges, {} facts, {} cached",
530        external_count,
531        edge_count,
532        fact_count,
533        cache_count,
534    );
535
536    // Phase 2: Deep indexing (background thread — BM25, Graph, Knowledge)
537    let cfg = crate::core::config::Config::load();
538    if !cfg.providers.auto_index {
539        return;
540    }
541
542    let project_root = ctx.project_root.clone();
543    std::thread::spawn(move || {
544        apply_artifacts_to_stores(&artifacts, &project_root);
545    });
546}
547
548/// Apply consolidation artifacts to BM25, Graph, and Knowledge stores.
549/// Called from a background thread after provider queries.
550pub fn apply_artifacts_to_stores(
551    artifacts: &consolidation::ConsolidationArtifacts,
552    project_root: &str,
553) {
554    let root_path = std::path::Path::new(project_root);
555
556    // BM25: load existing index, ingest provider chunks, save
557    if !artifacts.bm25_chunks.is_empty() {
558        let mut index = crate::core::bm25_index::BM25Index::load_or_build(root_path);
559        let ingested = index.ingest_content_chunks(artifacts.bm25_chunks.clone());
560        if ingested > 0 {
561            if let Err(e) = index.save(root_path) {
562                tracing::warn!("[ctx_provider] BM25 save failed: {e}");
563            } else {
564                tracing::info!("[ctx_provider] indexed {ingested} provider chunks into BM25");
565            }
566        }
567    }
568
569    // Graph: load existing index, merge cross-source edges, save
570    if !artifacts.edges.is_empty() {
571        let mut graph = crate::core::graph_index::load_or_build(project_root);
572        let added =
573            crate::core::cross_source_edges::merge_edges(&mut graph.edges, artifacts.edges.clone());
574        if added > 0 {
575            if let Err(e) = graph.save() {
576                tracing::warn!("[ctx_provider] graph save failed: {e}");
577            } else {
578                tracing::info!("[ctx_provider] added {added} cross-source edges to graph");
579            }
580        }
581    }
582
583    // Knowledge: load or create, remember extracted facts, save
584    if !artifacts.facts.is_empty() {
585        let policy = crate::core::memory_policy::MemoryPolicy::default();
586        let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(project_root)
587            .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(project_root));
588
589        let session_id = format!("provider-ingest-{}", chrono::Utc::now().timestamp());
590        for fact in &artifacts.facts {
591            knowledge.remember(
592                &fact.category,
593                &fact.key,
594                &fact.value,
595                &session_id,
596                fact.confidence,
597                &policy,
598            );
599        }
600
601        if let Err(e) = knowledge.save() {
602            tracing::warn!("[ctx_provider] knowledge save failed: {e}");
603        } else {
604            tracing::info!(
605                "[ctx_provider] remembered {} facts from provider data",
606                artifacts.facts.len()
607            );
608        }
609    }
610}
611
612fn format_chunks_compact(
613    chunks: &[crate::core::content_chunk::ContentChunk],
614    provider_id: &str,
615    resource: &str,
616) -> String {
617    let mut out = format!("{} results from {provider_id}/{resource}:\n", chunks.len());
618    for c in chunks {
619        out.push_str(&format!(
620            "  #{} {}\n",
621            c.file_path.rsplit('/').next().unwrap_or("?"),
622            c.symbol_name
623        ));
624    }
625    out
626}
627
628// ---------------------------------------------------------------------------
629// Legacy GitLab handlers (unchanged)
630// ---------------------------------------------------------------------------
631
632fn handle_gitlab_issues(args: &serde_json::Map<String, serde_json::Value>) -> String {
633    let config = match GitLabConfig::from_env() {
634        Ok(c) => c,
635        Err(e) => return format!("Error: {e}"),
636    };
637    let state = args.get("state").and_then(|v| v.as_str());
638    let labels = args.get("labels").and_then(|v| v.as_str());
639    let limit = args
640        .get("limit")
641        .and_then(serde_json::Value::as_u64)
642        .map(|n| n as usize);
643
644    match gitlab::list_issues(&config, state, labels, limit) {
645        Ok(result) => format_result(&result),
646        Err(e) => format!("Error: {e}"),
647    }
648}
649
650fn handle_gitlab_issue(args: &serde_json::Map<String, serde_json::Value>) -> String {
651    let config = match GitLabConfig::from_env() {
652        Ok(c) => c,
653        Err(e) => return format!("Error: {e}"),
654    };
655    let iid = args
656        .get("iid")
657        .and_then(serde_json::Value::as_u64)
658        .unwrap_or(0);
659    if iid == 0 {
660        return "Error: iid is required for gitlab_issue".to_string();
661    }
662
663    match gitlab::show_issue(&config, iid) {
664        Ok(result) => format_result(&result),
665        Err(e) => format!("Error: {e}"),
666    }
667}
668
669fn handle_gitlab_mrs(args: &serde_json::Map<String, serde_json::Value>) -> String {
670    let config = match GitLabConfig::from_env() {
671        Ok(c) => c,
672        Err(e) => return format!("Error: {e}"),
673    };
674    let state = args.get("state").and_then(|v| v.as_str());
675    let limit = args
676        .get("limit")
677        .and_then(serde_json::Value::as_u64)
678        .map(|n| n as usize);
679
680    match gitlab::list_mrs(&config, state, limit) {
681        Ok(result) => format_result(&result),
682        Err(e) => format!("Error: {e}"),
683    }
684}
685
686fn handle_gitlab_pipelines(args: &serde_json::Map<String, serde_json::Value>) -> String {
687    let config = match GitLabConfig::from_env() {
688        Ok(c) => c,
689        Err(e) => return format!("Error: {e}"),
690    };
691    let status = args.get("status").and_then(|v| v.as_str());
692    let limit = args
693        .get("limit")
694        .and_then(serde_json::Value::as_u64)
695        .map(|n| n as usize);
696
697    match gitlab::list_pipelines(&config, status, limit) {
698        Ok(result) => format_result(&result),
699        Err(e) => format!("Error: {e}"),
700    }
701}
702
703fn format_result(result: &ProviderResult) -> String {
704    crate::core::redaction::redact_text_if_enabled(&result.format_compact())
705}