Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 0 additions & 405 deletions README (Copy).md

This file was deleted.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Mint is a local-first AI assistant running on your machine, capable of handling

### 5. 🔌 Tool & MCP Integrations
- Support **Model Context Protocol (MCP)** to connect tools like Google/Brave Search, Filesystem servers, and GitHub context.
- **Auto GitHub Link Resolver:** Automatically detects GitHub URLs in chat messages (CLI, Web, and Desktop) and Code Agent tasks. It fetches and injects the repository's metadata, directory structure, and README as prompt context, serving as an instant fallback when the GitHub MCP server is not active.
- Local plugins for Spotify playback control, Google Calendar, Gmail drafts, and Notion workspace reading.

---
Expand Down Expand Up @@ -307,6 +308,7 @@ Mint includes native workspace tools for code inspection, planning, editing, and

```bash
mint code agent "inspect this repo and fix the failing tests"
mint code github-overview "Pheem49/Mint"
mint code summary .
mint code search "shell approval flow" .
mint symbols .
Expand Down
45 changes: 45 additions & 0 deletions crates/mint-cli/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,51 @@ fn confirm_pausing_interrupt(prompt: &str, approval_active: &AtomicBool) -> bool
}

fn format_markdown_bold(text: &str) -> String {
let mut formatted_lines = Vec::new();
let mut in_code_block = false;

for line in text.lines() {
let mut formatted_line = line.to_string();
let trimmed = line.trim_start();

if trimmed.starts_with("```") {
in_code_block = !in_code_block;
}

if !in_code_block {
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count > 0 && hash_count <= 6 {
let next_char = trimmed.chars().nth(hash_count);
let is_heading = match next_char {
Some(' ') => true,
Some(c) => !c.is_alphanumeric(),
None => true,
};
if is_heading {
// Make the entire heading line bold and bright white
formatted_line = format!("{}{}{}", BRIGHT, line, RESET);
} else {
formatted_line = process_inline_bold(&formatted_line);
}
} else {
formatted_line = process_inline_bold(&formatted_line);
}
} else {
// Do not format markdown inside code blocks
formatted_line = line.to_string();
}

formatted_lines.push(formatted_line);
}

let mut result = formatted_lines.join("\n");
if text.ends_with('\n') {
result.push('\n');
}
result
}

fn process_inline_bold(text: &str) -> String {
let count = text.matches("**").count();
let pair_limit = (count / 2) * 2;
let mut result = String::with_capacity(text.len());
Expand Down
51 changes: 51 additions & 0 deletions crates/mint-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use mint_core::{
load_config, native_plugins, orchestrate_chat_stream_with_fallback,
orchestrate_chat_with_fallback, propose_code_edits, read_code_file, repository_summary,
run_shell_command, search_code, search_semantic_code, set_config_value,
parse_github_url, fetch_github_repo_summary,
};

mod agent;
Expand Down Expand Up @@ -400,6 +401,11 @@ enum CodeCommand {
#[arg(long, default_value = ".")]
root: PathBuf,
},
/// Fetch GitHub repository metadata and README, then get an overview of the repo.
GithubOverview {
/// The GitHub repository URL or name (e.g. "https://github.com/owner/repo" or "owner/repo").
repo: String,
},
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -895,6 +901,9 @@ async fn main() -> Result<()> {
&config,
)?)?
),
CodeCommand::GithubOverview { repo } => {
run_github_overview(&repo, &config).await?;
}
}
}
Command::Open { target } => {
Expand Down Expand Up @@ -2737,6 +2746,48 @@ fn print_shell_output(output: &mint_core::ShellOutput) {
);
}



async fn run_github_overview(repo: &str, config: &MintConfig) -> Result<()> {
let Some((owner, repo_name)) = parse_github_url(repo) else {
anyhow::bail!("Invalid GitHub repository URL/format. Please use 'owner/repo' or a full GitHub URL.");
};

println!("Fetching information for {}/{} from GitHub...", owner, repo_name);
let summary = match fetch_github_repo_summary(&owner, &repo_name).await {
Ok(s) => s,
Err(e) => {
anyhow::bail!("Failed to fetch repository summary: {}. Check that the repository is public and spelled correctly.", e);
}
};

println!("Analyzing repository with AI model...");
let prompt = format!(
"Here is the metadata, top-level directory structure, and README.md content for the GitHub repository {}/{}:\n\n{}\n\nBased on this information, please provide a high-level overview of what this repository is about, what tech stack it uses, its overall architecture, and how it is organized.",
owner, repo_name, summary
);

let (response, _) = orchestrate_chat_with_fallback(
config,
&ChatRequest {
message: prompt,
system_instruction: "You are a professional software architect providing a high-level overview of a code repository based on its metadata and README.".to_string(),
chat_id: Some("github_review".to_string()),
image_data_uri: None,
audio_data_uri: None,
document_attachment: None,
workspace_path: None,
},
)
.await?;

println!("\n--- AI Repository Overview for {}/{} ---", owner, repo_name);
println!("{}", response.text);
println!("--------------------------------------------------");
Ok(())
}


#[cfg(test)]
mod tests {
use super::*;
Expand Down
95 changes: 95 additions & 0 deletions crates/mint-core/src/code_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,101 @@ fn is_ignored_directory(path: &Path) -> bool {
.is_some_and(|name| IGNORED_DIRECTORIES.contains(&name))
}

pub fn parse_github_url(url: &str) -> Option<(String, String)> {
let cleaned = url.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("www.")
.trim_start_matches("github.com/");

let parts: Vec<&str> = cleaned.split('/').collect();
if parts.len() >= 2 {
let owner = parts[0].to_string();
let mut repo = parts[1].to_string();
if repo.ends_with(".git") {
repo = repo[..repo.len() - 4].to_string();
}
Some((owner, repo))
} else {
None
}
}

pub async fn fetch_github_repo_summary(owner: &str, repo: &str) -> Result<String, String> {
let client = reqwest::Client::builder()
.user_agent("mint-core")
.build()
.map_err(|e| e.to_string())?;

// 1. Fetch Repository Info
let repo_url = format!("https://api.github.com/repos/{}/{}", owner, repo);
let repo_resp = client.get(&repo_url).send().await.map_err(|e| e.to_string())?;
if !repo_resp.status().is_success() {
return Err(format!("Failed to fetch repository metadata: {}", repo_resp.status()));
}
let repo_info: serde_json::Value = repo_resp.json().await.map_err(|e| e.to_string())?;

let description = repo_info["description"].as_str().unwrap_or("No description provided.");
let language = repo_info["language"].as_str().unwrap_or("Unknown");
let stars = repo_info["stargazers_count"].as_u64().unwrap_or(0);
let forks = repo_info["forks_count"].as_u64().unwrap_or(0);

let mut topics_list = Vec::new();
if let Some(topics) = repo_info["topics"].as_array() {
for t in topics {
if let Some(t_str) = t.as_str() {
topics_list.push(t_str.to_string());
}
}
}
let topics_str = if topics_list.is_empty() {
"None".to_string()
} else {
topics_list.join(", ")
};

// 2. Fetch Directory contents (top level)
let contents_url = format!("https://api.github.com/repos/{}/{}/contents", owner, repo);
let contents_resp = client.get(&contents_url).send().await.map_err(|e| e.to_string())?;
let mut file_tree = String::from("Unavailable");
if contents_resp.status().is_success() {
if let Ok(contents_info) = contents_resp.json::<serde_json::Value>().await {
if let Some(arr) = contents_info.as_array() {
let mut files = Vec::new();
for item in arr {
let name = item["name"].as_str().unwrap_or("");
let r#type = item["type"].as_str().unwrap_or("");
files.push(format!("- {} ({})", name, r#type));
}
file_tree = files.join("\n");
}
}
}

// 3. Fetch README.md
let readme_url = format!("https://api.github.com/repos/{}/{}/readme", owner, repo);
let readme_resp = client.get(&readme_url).send().await.map_err(|e| e.to_string())?;
let mut readme_text = String::from("No README available.");
if readme_resp.status().is_success() {
if let Ok(readme_info) = readme_resp.json::<serde_json::Value>().await {
if let Some(content_b64) = readme_info["content"].as_str() {
let cleaned_b64 = content_b64.replace('\n', "").replace('\r', "");
use base64::{Engine as _, engine::general_purpose::STANDARD};
if let Ok(decoded_bytes) = STANDARD.decode(cleaned_b64) {
readme_text = String::from_utf8_lossy(&decoded_bytes).to_string();
}
}
}
}

let summary = format!(
"Repository: {}/{}\nDescription: {}\nPrimary Language: {}\nStars: {}\nForks: {}\nTopics: {}\n\nTop-level File Directory:\n{}\n\nREADME.md:\n{}",
owner, repo, description, language, stars, forks, topics_str, file_tree, readme_text
);

Ok(summary)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion crates/mint-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ pub use code_tools::{
AppliedCodeEdit, CodeEdit, CodeEditPreview, CodeEditProposal, CodeFile, CodeInspectionError,
CodePatchHunk, CodePlan, CodeSearchHit, RepositorySummary, apply_code_edits, build_code_patch,
inspect_code_plan, list_code_files, propose_code_edits, read_code_file, repository_summary,
search_code,
search_code, parse_github_url, fetch_github_repo_summary,
};
pub use config::{
ConfigError, MintConfig, config_path, initialize_config, load_config, save_config,
Expand Down
64 changes: 59 additions & 5 deletions crates/mint-core/src/orchestration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,59 @@ pub enum OrchestrationError {
Agent(String),
}

pub async fn resolve_github_links(message: &str, config: &MintConfig) -> String {
// Check if a GitHub MCP server is configured in Settings
let github_mcp_configured = crate::mcp::configured_mcp_servers(config)
.ok()
.map(|servers| servers.contains_key("github"))
.unwrap_or(false);

if github_mcp_configured {
// If GitHub MCP is active, we let it handle the repo via tool calls
// to avoid duplicate/redundant context.
return message.to_string();
}

use std::sync::OnceLock;
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
regex::Regex::new(r"https?://(?:www\.)?github\.com/([a-zA-Z0-9\-_.]+)/([a-zA-Z0-9\-_.]+)").unwrap()
});

let mut resolved_msg = message.to_string();
let mut resolved_repos = std::collections::HashSet::new();

for caps in re.captures_iter(message) {
if let (Some(owner_match), Some(repo_match)) = (caps.get(1), caps.get(2)) {
let owner = owner_match.as_str();
let mut repo = repo_match.as_str().to_string();
if repo.ends_with(".git") {
repo = repo[..repo.len() - 4].to_string();
}
let repo_clean: String = repo.chars().take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.').collect();

let repo_key = format!("{owner}/{repo_clean}");
if resolved_repos.insert(repo_key.clone()) {
if let Ok(summary) = crate::code_tools::fetch_github_repo_summary(owner, &repo_clean).await {
resolved_msg.push_str(&format!(
"\n\n--- Auto-Resolved GitHub Metadata for {} ---\n{}\n--------------------------------------------",
repo_key, summary
));
}
}
}
}
resolved_msg
}

pub async fn orchestrate_chat(
config: &MintConfig,
request: &ChatRequest,
) -> Result<ChatResponse, OrchestrationError> {
let mut resolved_request = request.clone();
resolved_request.message = resolve_github_links(&request.message, config).await;
let memory = MemoryStore::open_default()?;
let enriched = enrich_request(&memory, request)?;
let enriched = enrich_request(&memory, &resolved_request)?;
let response = send_chat(config, &enriched).await?;
memory.add_interaction_for_chat_with_fallback(
request_chat_id(request),
Expand All @@ -66,8 +113,10 @@ pub async fn orchestrate_chat_stream<F>(
where
F: FnMut(String),
{
let mut resolved_request = request.clone();
resolved_request.message = resolve_github_links(&request.message, config).await;
let memory = MemoryStore::open_default()?;
let enriched = enrich_request(&memory, request)?;
let enriched = enrich_request(&memory, &resolved_request)?;
let response = stream_chat(config, &enriched, on_chunk).await?;
memory.add_interaction_for_chat_with_fallback(
request_chat_id(request),
Expand All @@ -89,8 +138,10 @@ pub async fn orchestrate_chat_with_fallback(
config: &MintConfig,
request: &ChatRequest,
) -> Result<(ChatResponse, Option<String>), OrchestrationError> {
let mut resolved_request = request.clone();
resolved_request.message = resolve_github_links(&request.message, config).await;
let memory = MemoryStore::open_default()?;
let enriched = enrich_request(&memory, request)?;
let enriched = enrich_request(&memory, &resolved_request)?;
let (response, fallback) = send_chat_with_fallback(config, &enriched).await?;
memory.add_interaction_for_chat_with_fallback(
request_chat_id(request),
Expand All @@ -116,8 +167,10 @@ pub async fn orchestrate_chat_stream_with_fallback<F>(
where
F: FnMut(String),
{
let mut resolved_request = request.clone();
resolved_request.message = resolve_github_links(&request.message, config).await;
let memory = MemoryStore::open_default()?;
let enriched = enrich_request(&memory, request)?;
let enriched = enrich_request(&memory, &resolved_request)?;
let (response, fallback) = stream_chat_with_fallback(config, &enriched, on_chunk).await?;
memory.add_interaction_for_chat_with_fallback(
request_chat_id(request),
Expand Down Expand Up @@ -572,8 +625,9 @@ where
e
))
})?;
let resolved_task = resolve_github_links(task, config).await;
let skills = crate::skills::learned_skills_context().unwrap_or_default();
let mut observation = initial_observation(task, &root, &skills);
let mut observation = initial_observation(&resolved_task, &root, &skills);
let mut pending_image = image_data_uri;

let mut system_prompt = build_system_prompt(config);
Expand Down
3 changes: 0 additions & 3 deletions src/renderer/src-web/components/PicturesLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@ export default function PicturesLibrary({ view, pictures, onSetView }: PicturesL
<div><span className="pictures-kicker">Gallery</span><h2>Saved Pictures</h2></div>
<div className="pictures-header-actions">
<button className="pictures-close-btn" onClick={() => onSetView('chat')}>Close Gallery</button>
<button type="button" className="picture-folder-btn" disabled={pictures.length === 0} onClick={() => window.settingsApi?.openFolder(pictures[0]?.path || '')}>
Open Folder
</button>
</div>
</header>
{pictures.length === 0 ? (
Expand Down
Loading