Compare commits

...

7 Commits

Author SHA1 Message Date
ZhenYi
bba35f1b2c fix(room): increase streaming timeout from 60s to 120s
Some checks failed
CI / Rust Lint & Check (push) Has been cancelled
CI / Rust Tests (push) Has been cancelled
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
Give more buffer for heartbeat chunks to arrive during long tool
execution, reducing false "Stream timed out" errors.
2026-04-29 09:03:33 +08:00
ZhenYi
03f97c9221 fix(agent): spawn tool execution in separate task for heartbeat
Move tool execution to a spawned task so synchronous git2 operations
don't block the tokio worker thread, allowing heartbeat chunks to be
sent every 10s during long tool execution.

Also add analysis-first reasoning prompt to system messages.
2026-04-29 09:03:29 +08:00
ZhenYi
30822bbd7d fix(skill): support bare repo scanning via git tree traversal
Add scan_repo_tree_for_skills and scan_skills_from_tree functions that
traverse git objects directly instead of filesystem, enabling skill
discovery in bare repositories created via git2::Repository::init_bare.
2026-04-29 09:03:22 +08:00
ZhenYi
b673c31485 feat(fctool): register new git tools in mod.rs
Register repo_analysis, kb, and repo_util tool modules in the
git_tools register_all function.
2026-04-29 09:03:13 +08:00
ZhenYi
a5704c9730 feat(fctool): add repo utility tools for AI
Add repo_search, repo_readme, repo_commit_log, repo_contributors,
and repo_diff_summary function call tools for AI to search code,
read README, query commit history, list contributors, and diff revisions.
2026-04-29 09:03:03 +08:00
ZhenYi
4ef0d5b570 feat(fctool): add knowledge base tools for AI
Add repo_doc_index, repo_doc_read, and repo_doc_search function call
tools for AI to index, read, and search through documentation repos.
2026-04-29 09:02:56 +08:00
ZhenYi
5f12b07120 feat(fctool): add repo analysis tools for AI
Add repo_overview, repo_file_tree, repo_languages, and repo_dependencies
function call tools for AI to quickly analyze repository structure,
language breakdown, and dependency manifests.
2026-04-29 09:02:51 +08:00
8 changed files with 1729 additions and 32 deletions

View File

@ -685,17 +685,30 @@ impl ChatService {
for call in &calls {
let start = std::time::Instant::now();
let executor = crate::tool::ToolExecutor::new();
// Use select! loop to send heartbeat chunks at 30s intervals
// during long tool execution, resetting the frontend streaming timer.
let fut = executor.execute_batch(vec![call.clone()], &mut ctx);
tokio::pin!(fut);
// Spawn tool execution in a separate task to avoid blocking the
// tokio worker thread (git2 operations are synchronous).
// This allows the heartbeat timer to fire independently.
let call_clone = call.clone();
let mut ctx_clone = ctx.clone();
let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let executor = crate::tool::ToolExecutor::new();
let res = executor.execute_batch(vec![call_clone], &mut ctx_clone).await;
let _ = result_tx.send(res);
});
// Send heartbeats every 10s until tool execution completes
let heartbeat_dur = std::time::Duration::from_secs(10);
let results = loop {
tokio::select! {
result = fut.as_mut() => break result,
_ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
res = &mut result_rx => {
match res {
Ok(inner) => break inner,
Err(_) => break Err(crate::tool::ToolError::ExecutionError("tool task cancelled".into())),
}
},
_ = tokio::time::sleep(heartbeat_dur) => {
on_chunk(AiStreamChunk {
content: String::new(),
done: false,
@ -908,6 +921,17 @@ impl ChatService {
async fn build_messages(&self, request: &AiRequest) -> Result<Vec<ChatRequestMessage>> {
let mut messages = Vec::new();
// Core reasoning instruction — prioritize analysis before answering.
messages.push(ChatRequestMessage::system(
"When receiving a question or problem, follow this reasoning process:\n\
1. ANALYZE: Break down the question. Identify what is being asked, what context is available, and what information is missing.\n\
2. GATHER: Use available tools (repository search, file reading, etc.) to collect relevant information before answering.\n\
3. REASON: Synthesize the gathered information. Consider edge cases and potential issues.\n\
4. ANSWER: Provide a clear, actionable answer based on your analysis.\n\
\n\
Do NOT guess or assume when tools can provide concrete answers. Always verify claims against actual code or documentation.".to_string()
));
let mut processed_history = Vec::new();
if let Some(compact_service) = &self.compact_service {
let config = CompactConfig::default();

View File

@ -0,0 +1,384 @@
//! Knowledge-base (documentation) repository tools for AI.
//!
//! Provides tools for AI to quickly index, read, and search
//! through documentation / knowledge-base repositories.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
/// Extract frontmatter (--- ... ---) from markdown content.
fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") {
return (None, trimmed);
}
if let Some(end) = trimmed[3..].find("---") {
let fm = &trimmed[3..end + 3];
let rest = trimmed[3 + end + 3..].trim_start();
(Some(fm), rest)
} else {
(None, trimmed)
}
}
/// Extract all headings (lines starting with #) from markdown body.
fn extract_headings(body: &str) -> Vec<serde_json::Value> {
body.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("# ") {
Some(serde_json::json!({ "level": 1, "text": trimmed[2..].trim() }))
} else if trimmed.starts_with("## ") {
Some(serde_json::json!({ "level": 2, "text": trimmed[3..].trim() }))
} else if trimmed.starts_with("### ") {
Some(serde_json::json!({ "level": 3, "text": trimmed[4..].trim() }))
} else if trimmed.starts_with("#### ") {
Some(serde_json::json!({ "level": 4, "text": trimmed[5..].trim() }))
} else {
None
}
})
.collect()
}
/// Resolve HEAD to a tree for traversal.
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_doc_index — list all markdown docs with frontmatter
async fn repo_doc_index_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut docs = Vec::new();
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let raw = String::from_utf8_lossy(blob.content());
let (fm_raw, body) = extract_frontmatter(&raw);
let metadata: serde_json::Value = fm_raw
.and_then(|fm| serde_json::from_str(fm).ok())
.unwrap_or_default();
let title = metadata
.get("title")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
// Fall back to first # heading
body.lines()
.find(|l| l.trim().starts_with("# "))
.map(|l| l.trim()[2..].trim().to_string())
});
let description = metadata
.get("description")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
// Fall back to first non-heading non-empty line
body.lines()
.find(|l| {
let t = l.trim();
!t.is_empty() && !t.starts_with('#')
})
.map(|l| l.trim().chars().take(200).collect::<String>())
});
let tags: Vec<String> = metadata
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let headings = extract_headings(body);
docs.push(serde_json::json!({
"path": entry_path,
"title": title,
"description": description,
"tags": tags,
"headings": headings,
"size": raw.len(),
}));
}
}
}
_ => {}
}
}
}
// Sort by path for consistent ordering
docs.sort_by(|a, b| {
a["path"].as_str().unwrap_or("").cmp(b["path"].as_str().unwrap_or(""))
});
Ok(serde_json::json!({
"total": docs.len(),
"docs": docs
}))
}
/// Tool: repo_doc_read — read a specific document with structure
async fn repo_doc_read_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Navigate to the file using git2 path lookup
let entry = tree.get_path(std::path::Path::new(path))
.map_err(|e| format!("file '{}' not found: {e}", path))?;
let blob = entry.to_object(repo).and_then(|o| o.peel_to_blob())
.map_err(|e| format!("not a blob: {e}"))?;
let raw = String::from_utf8_lossy(blob.content());
let (fm_raw, body) = extract_frontmatter(&raw);
let metadata: serde_json::Value = fm_raw
.and_then(|fm| serde_json::from_str(fm).ok())
.unwrap_or_default();
let title = metadata
.get("title")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(|| {
body.lines()
.find(|l| l.trim().starts_with("# "))
.map(|l| l.trim()[2..].trim().to_string())
});
let headings = extract_headings(body);
Ok(serde_json::json!({
"path": path,
"title": title,
"metadata": metadata,
"headings": headings,
"content": body.to_string(),
"size": raw.len(),
}))
}
/// Tool: repo_doc_search — search through docs content
async fn repo_doc_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let keyword_lower = keyword.to_lowercase();
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut matches: Vec<serde_json::Value> = Vec::new();
let mut matched_files = 0u64;
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') && !matches!(name, "node_modules" | "target" | ".git" | ".github" | ".next" | "dist") {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".mdx") || name.ends_with(".markdown") || name.ends_with(".txt") {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
let mut file_hits: Vec<serde_json::Value> = Vec::new();
let mut hit_lines = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.to_lowercase().contains(&keyword_lower) {
hit_lines.push(i);
}
}
if !hit_lines.is_empty() {
matched_files += 1;
// Merge overlapping context windows
let mut windows: Vec<(usize, usize)> = Vec::new();
for &line_idx in &hit_lines {
let start = line_idx.saturating_sub(context_lines);
let end = (line_idx + context_lines + 1).min(lines.len());
if let Some(last) = windows.last_mut() {
if start <= last.1 {
last.1 = end;
continue;
}
}
windows.push((start, end));
}
for (start, end) in windows {
let snippet: Vec<String> = lines[start..end]
.iter()
.map(|l| l.to_string())
.collect();
file_hits.push(serde_json::json!({
"line_start": start + 1,
"line_end": end,
"snippet": snippet.join("\n"),
}));
}
matches.push(serde_json::json!({
"path": entry_path,
"hit_count": hit_lines.len(),
"snippets": file_hits,
}));
}
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"keyword": keyword,
"matched_files": matched_files,
"total_hits": matches.iter().map(|m| m["hit_count"].as_u64().unwrap_or(0)).sum::<u64>(),
"matches": matches,
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_doc_index
registry.register(
ToolDefinition::new("repo_doc_index")
.description("Index all documentation files in a knowledge-base repository. Lists every .md/.mdx file with its title, description, tags, and heading structure. Use this first to understand what documents are available.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_index_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_doc_read
registry.register(
ToolDefinition::new("repo_doc_read")
.description("Read a specific document from a knowledge-base repository. Returns the full markdown content plus extracted frontmatter metadata and heading structure. Use this after repo_doc_index to read the documents you need.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("path", "string", "Document file path within the repository", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_read_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_doc_search
registry.register(
ToolDefinition::new("repo_doc_search")
.description("Search through all documentation files in a knowledge-base repository for a keyword. Returns matching file paths, hit counts, and context snippets. Use this to find which documents discuss a specific topic.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("keyword", "string", "Search keyword (case-insensitive)", true),
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_doc_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -8,6 +8,9 @@ pub mod branch;
pub mod commit;
pub mod ctx;
pub mod diff;
pub mod kb;
pub mod repo_analysis;
pub mod repo_util;
pub mod tag;
pub mod tree;
pub mod types;
@ -20,4 +23,7 @@ pub fn register_all(registry: &mut agent::ToolRegistry) {
blob::register_git_tools(registry);
tree::register_git_tools(registry);
tag::register_git_tools(registry);
repo_analysis::register_git_tools(registry);
kb::register_git_tools(registry);
repo_util::register_git_tools(registry);
}

View File

@ -0,0 +1,627 @@
//! Repository analysis tools for AI.
//!
//! Provides function-calling tools that let AI quickly understand
//! repository structure, languages, dependencies, and overview.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
/// Recognised dependency manifest file names and their parser labels.
const DEPENDENCY_MANIFESTS: &[(&str, &str)] = &[
("Cargo.toml", "rust"),
("package.json", "node"),
("go.mod", "go"),
("go.sum", "go"),
("Gemfile", "ruby"),
("requirements.txt", "python"),
("Pipfile", "python"),
("pyproject.toml", "python"),
("pom.xml", "java"),
("build.gradle", "java"),
("build.gradle.kts", "java"),
("composer.json", "php"),
("CMakeLists.txt", "cmake"),
("Makefile", "make"),
];
/// Language detection by file extension (lowercase).
fn ext_to_language(ext: &str) -> Option<&'static str> {
match ext {
"rs" => Some("Rust"),
"go" => Some("Go"),
"py" => Some("Python"),
"js" => Some("JavaScript"),
"jsx" => Some("JSX"),
"ts" => Some("TypeScript"),
"tsx" => Some("TSX"),
"java" => Some("Java"),
"kt" | "kts" => Some("Kotlin"),
"rb" => Some("Ruby"),
"php" => Some("PHP"),
"c" => Some("C"),
"h" => Some("C/C++ Header"),
"cpp" | "cc" | "cxx" => Some("C++"),
"hpp" | "hh" => Some("C++ Header"),
"cs" => Some("C#"),
"swift" => Some("Swift"),
"scala" => Some("Scala"),
"zig" => Some("Zig"),
"sh" | "bash" | "zsh" => Some("Shell"),
"ps1" => Some("PowerShell"),
"sql" => Some("SQL"),
"html" | "htm" => Some("HTML"),
"css" | "scss" | "sass" | "less" => Some("CSS"),
"json" => Some("JSON"),
"yaml" | "yml" => Some("YAML"),
"toml" => Some("TOML"),
"md" => Some("Markdown"),
"dockerfile" | "containerfile" => Some("Dockerfile"),
"proto" => Some("Protobuf"),
"vue" => Some("Vue"),
"svelte" => Some("Svelte"),
"lua" => Some("Lua"),
"dart" => Some("Dart"),
"r" | "R" => Some("R"),
"clj" | "cljs" | "cljc" => Some("Clojure"),
"ex" | "exs" => Some("Elixir"),
"erl" => Some("Erlang"),
"hs" => Some("Haskell"),
_ => None,
}
}
/// Directories that should be ignored in file-tree scans.
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
)
}
/// Recursively collect file extensions and counts from a git tree.
/// Skips ignored directories and binary-looking files.
fn collect_languages(
repo: &git2::Repository,
tree: &git2::Tree,
prefix: &str,
stats: &mut HashMap<String, (String, u64)>,
max_files: u64,
) {
let mut count = 0u64;
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree.clone(), prefix.to_string())];
while let Some((current_tree, path)) = stack.pop() {
for entry in current_tree.iter() {
if max_files > 0 && count >= max_files {
return;
}
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if path.is_empty() {
name.to_string()
} else {
format!("{}/{}", path, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) =
entry.to_object(repo).and_then(|o| o.peel_to_tree())
{
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
count += 1;
if let Some(ext) = name.rsplit('.').next() {
let ext = ext.to_lowercase();
if let Some(lang) = ext_to_language(&ext) {
let entry = stats
.entry(lang.to_string())
.or_insert_with(|| (ext, 0));
entry.1 += 1;
}
}
}
_ => {}
}
}
}
}
/// Collect a recursive file tree (path + kind) up to a given depth and file limit.
fn collect_file_tree(
repo: &git2::Repository,
tree: &git2::Tree,
prefix: &str,
depth: usize,
max_depth: usize,
max_files: u64,
files: &mut Vec<serde_json::Value>,
) {
if depth > max_depth {
return;
}
for entry in tree.iter() {
if max_files > 0 && files.len() as u64 >= max_files {
return;
}
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
files.push(serde_json::json!({
"path": entry_path,
"kind": "dir"
}));
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
collect_file_tree(repo, &subtree, &entry_path, depth + 1, max_depth, max_files, files);
}
}
}
Some(git2::ObjectType::Blob) => {
files.push(serde_json::json!({
"path": entry_path,
"kind": "file"
}));
}
_ => {}
}
}
}
/// Detect config/manifest files in the root tree and return their names.
fn detect_config_files(tree: &git2::Tree) -> Vec<String> {
let mut configs = Vec::new();
let known_configs = [
"Cargo.toml", "package.json", "go.mod", "Gemfile", "README.md",
"Dockerfile", "docker-compose.yml", "docker-compose.yaml",
".github/workflows", ".gitignore", ".gitattributes",
"Makefile", "CMakeLists.txt", "composer.json", "pyproject.toml",
"requirements.txt", "Pipfile", "pom.xml", "build.gradle",
"build.gradle.kts", "settings.gradle", "settings.gradle.kts",
"tsconfig.json", ".eslintrc.js", ".eslintrc.json",
"prettier.config.js", "prettierrc", "webpack.config.js",
"vite.config.ts", "vite.config.js", "next.config.js",
"nuxt.config.ts", "svelte.config.js",
"rust-toolchain", "rust-toolchain.toml",
"clippy.toml", ".rustfmt.toml", "rustfmt.toml",
"renovate.json", ".renovaterc", ".mergify.yml",
"docker-bake.hcl", ".dockerignore",
"Cargo.lock", "yarn.lock", "package-lock.json", "pnpm-lock.yaml",
"Gemfile.lock", "Cargo.lock",
];
for entry in tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
if known_configs.contains(&name) || name.starts_with('.') && !name.starts_with(".git") {
configs.push(name.to_string());
}
}
configs.sort();
configs.dedup();
configs
}
/// Parse a dependency manifest file content and return a structured summary.
fn parse_dependencies(content: &str, manifest_name: &str) -> serde_json::Value {
match manifest_name {
"Cargo.toml" => {
// Simple TOML-ish parsing for [dependencies] section
let mut deps = Vec::new();
let mut in_deps = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("[dependencies]") {
in_deps = true;
continue;
}
if trimmed.starts_with('[') {
in_deps = false;
continue;
}
if in_deps {
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim().to_string();
let version = trimmed[eq_pos + 1..].trim().trim_matches('"').trim_matches('\'').to_string();
if !name.is_empty() && !name.starts_with('#') {
deps.push(serde_json::json!({ "name": name, "version": version }));
}
} else if !trimmed.is_empty() && !trimmed.starts_with('#') {
// bare dependency name (path/git dep without explicit version)
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
}
}
}
serde_json::json!({ "manifest": "Cargo.toml", "ecosystem": "rust", "dependencies": deps })
}
"package.json" => {
let mut deps = Vec::new();
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) {
for section in &["dependencies", "devDependencies", "peerDependencies"] {
if let Some(map) = parsed.get(*section).and_then(|v| v.as_object()) {
for (name, version) in map {
deps.push(serde_json::json!({
"name": name,
"version": version.as_str().unwrap_or("*"),
"scope": section
}));
}
}
}
}
serde_json::json!({ "manifest": "package.json", "ecosystem": "node", "dependencies": deps })
}
"go.mod" => {
let mut deps = Vec::new();
let mut in_require = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("require (") || trimmed == "require (" {
in_require = true;
continue;
}
if trimmed == ")" {
in_require = false;
continue;
}
if in_require {
let parts: Vec<&str> = trimmed.split_whitespace().collect();
if parts.len() >= 2 {
deps.push(serde_json::json!({ "name": parts[0], "version": parts[1] }));
}
}
}
serde_json::json!({ "manifest": "go.mod", "ecosystem": "go", "dependencies": deps })
}
"Gemfile" => {
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("gem ") {
let rest = trimmed.trim_start_matches("gem ");
let name = rest.split(',').next().unwrap_or(rest).trim().trim_matches('"').trim_matches('\'');
let version = rest.split(',').nth(1).map(|v| v.trim().trim_matches('"').trim_matches('\''));
deps.push(serde_json::json!({ "name": name, "version": version }));
}
}
serde_json::json!({ "manifest": "Gemfile", "ecosystem": "ruby", "dependencies": deps })
}
"requirements.txt" => {
let mut deps = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() && !trimmed.starts_with('#') && !trimmed.starts_with("-r") && !trimmed.starts_with("--") {
if let Some(eq_eq) = trimmed.find("==") {
let name = trimmed[..eq_eq].trim().to_string();
let version = trimmed[eq_eq + 2..].trim().to_string();
deps.push(serde_json::json!({ "name": name, "version": version }));
} else {
deps.push(serde_json::json!({ "name": trimmed, "version": null }));
}
}
}
serde_json::json!({ "manifest": "requirements.txt", "ecosystem": "python", "dependencies": deps })
}
_ => serde_json::json!({ "manifest": manifest_name, "ecosystem": "unknown", "dependencies": [] }),
}
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Resolve HEAD to a tree for traversal.
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
/// Resolve HEAD to a commit OID.
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.target()
.map(|o| o.to_string())
.ok_or_else(|| "HEAD has no target".to_string())
}
/// Tool: repo_overview — quick project overview
async fn repo_overview_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Default branch
let default_branch = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string());
// Config files in root
let config_files = detect_config_files(&tree);
// Language stats (up to 5000 files)
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
collect_languages(repo, &tree, "", &mut lang_stats, 5000);
let mut languages: Vec<serde_json::Value> = lang_stats
.into_iter()
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
// Top-level file tree
let mut root_files: Vec<serde_json::Value> = Vec::new();
collect_file_tree(repo, &tree, "", 0, 1, 100, &mut root_files);
// Recent commits (last 10)
let head_oid = head_oid(&domain)?;
let recent_commits = domain
.commit_log(Some(&head_oid), 0, 10)
.map_err(|e| e.to_string())?;
let commits: Vec<serde_json::Value> = recent_commits
.iter()
.map(|c| {
serde_json::json!({
"oid": c.oid.to_string(),
"summary": c.summary,
"author": c.author.name,
"time": c.author.time_secs,
})
})
.collect();
// Total commit count
let total_commits = domain.commit_total(Some(&head_oid)).unwrap_or(0);
Ok(serde_json::json!({
"default_branch": default_branch,
"head_oid": head_oid,
"total_commits": total_commits,
"config_files": config_files,
"languages": languages,
"top_level_entries": root_files,
"recent_commits": commits,
}))
}
/// Tool: repo_file_tree — recursive file tree with depth/ignore
async fn repo_file_tree_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let max_depth = p.get("max_depth").and_then(|v| v.as_u64()).unwrap_or(3) as usize;
let max_files = p.get("max_files").and_then(|v| v.as_u64()).unwrap_or(200);
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut files = Vec::new();
collect_file_tree(repo, &tree, "", 0, max_depth, max_files, &mut files);
Ok(serde_json::json!({
"total": files.len(),
"entries": files
}))
}
/// Tool: repo_languages — detailed language breakdown
async fn repo_languages_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut lang_stats: HashMap<String, (String, u64)> = HashMap::new();
collect_languages(repo, &tree, "", &mut lang_stats, 100_000);
let mut languages: Vec<serde_json::Value> = lang_stats
.into_iter()
.map(|(lang, (_ext, count))| serde_json::json!({ "language": lang, "file_count": count }))
.collect();
languages.sort_by(|a, b| {
b["file_count"].as_u64().unwrap_or(0)
.cmp(&a["file_count"].as_u64().unwrap_or(0))
});
Ok(serde_json::json!({
"total_languages": languages.len(),
"languages": languages
}))
}
/// Tool: repo_dependencies — parse dependency manifests
async fn repo_dependencies_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let tree = head_tree(&domain)?;
// Walk the tree looking for dependency manifests at any depth
let mut manifests: Vec<serde_json::Value> = Vec::new();
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
let repo = domain.repo();
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if DEPENDENCY_MANIFESTS.iter().any(|(fname, _)| *fname == name) {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content());
let manifest_type = DEPENDENCY_MANIFESTS
.iter()
.find(|(fname, _)| *fname == name)
.map(|(_, eco)| eco)
.unwrap_or(&"unknown");
let parsed = parse_dependencies(&content, name);
manifests.push(serde_json::json!({
"path": entry_path,
"ecosystem": manifest_type,
"details": parsed
}));
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"manifest_count": manifests.len(),
"manifests": manifests
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_overview
registry.register(
ToolDefinition::new("repo_overview")
.description("Get a quick overview of a repository: default branch, detected config files, language breakdown by file count, top-level directory entries, and recent commits. Ideal for first contact with a repo.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_overview_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_file_tree
registry.register(
ToolDefinition::new("repo_file_tree")
.description("List files and directories in a repository recursively with configurable depth. Ignores common generated/artifact directories (node_modules, target, .git, etc.). Useful for understanding project layout.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("max_depth", "integer", "Maximum directory depth to traverse (default: 3)", false),
param!("max_files", "integer", "Maximum number of entries to return (default: 200)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_file_tree_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_languages
registry.register(
ToolDefinition::new("repo_languages")
.description("Get a detailed breakdown of programming languages used in a repository, sorted by file count. Scans all files in the repo (up to 100K files) and maps extensions to language names.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_languages_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_dependencies
registry.register(
ToolDefinition::new("repo_dependencies")
.description("Discover and parse dependency manifests (Cargo.toml, package.json, go.mod, Gemfile, requirements.txt, etc.) in a repository. Returns structured dependency lists per manifest.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_dependencies_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -0,0 +1,512 @@
//! General-purpose repository utility tools for AI.
//!
//! Code search, README reading, commit history, contributors, and diff summaries.
use super::ctx::GitToolCtx;
use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema};
use std::collections::HashMap;
// ── Helpers ────────────────────────────────────────────────────────────────────
fn head_tree(domain: &git::GitDomain) -> Result<git2::Tree<'_>, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.peel_to_tree().map_err(|e| format!("no tree: {e}"))
}
fn head_oid(domain: &git::GitDomain) -> Result<String, String> {
let repo = domain.repo();
let head = repo.head().map_err(|e| format!("no HEAD: {e}"))?;
head.target()
.map(|o| o.to_string())
.ok_or_else(|| "HEAD has no target".to_string())
}
fn is_ignored_dir(name: &str) -> bool {
matches!(
name,
".git" | "node_modules" | "target" | "dist" | "build" | ".next"
| ".nuxt" | ".output" | ".cache" | "__pycache__" | ".tox"
| "vendor" | ".bundle" | ".gradle" | "bin" | "obj"
| ".svn" | ".hg" | ".idea" | ".vscode" | "coverage"
| ".terraform" | ".serverless" | "deps" | "_build"
| "elm-stuff" | ".stack-work" | ".pytest_cache"
)
}
fn is_binary_ext(name: &str) -> bool {
match name.rsplit('.').next().unwrap_or("") {
"png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "svg" | "bmp"
| "mp3" | "mp4" | "wav" | "avi" | "mov" | "mkv" | "webm"
| "zip" | "tar" | "gz" | "bz2" | "xz" | "7z" | "rar"
| "exe" | "dll" | "so" | "dylib" | "o" | "a" | "lib"
| "woff" | "woff2" | "ttf" | "otf" | "eot"
| "pdf" | "doc" | "docx" | "xls" | "xlsx" | "ppt" | "pptx"
| "sqlite" | "db" | "bin" | "dat" | "pyc" | "class"
| "wasm" | "node" => true,
_ => false,
}
}
/// Resolve a rev string to a commit OID.
fn resolve_commit_oid(domain: &git::GitDomain, rev: &str) -> Result<git::commit::types::CommitOid, String> {
domain.resolve_rev(rev).map_err(|e| e.to_string())
}
// ── Tool executors ─────────────────────────────────────────────────────────────
/// Tool: repo_search — search code content across the repo
async fn repo_search_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let keyword = p.get("keyword").and_then(|v| v.as_str()).ok_or("missing keyword")?;
let context_lines = p.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(2) as usize;
let max_results = p.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let keyword_lower = keyword.to_lowercase();
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
let mut matches: Vec<serde_json::Value> = Vec::new();
let mut matched_files = 0usize;
let mut total_hits = 0usize;
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
'outer: while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
if matches.len() >= max_results {
break 'outer;
}
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !is_ignored_dir(name) && !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) => {
if is_binary_ext(name) {
continue;
}
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
if blob.is_binary() {
continue;
}
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
let mut hit_lines = Vec::new();
for (i, line) in lines.iter().enumerate() {
if line.to_lowercase().contains(&keyword_lower) {
hit_lines.push(i);
}
}
if !hit_lines.is_empty() {
matched_files += 1;
total_hits += hit_lines.len();
// Merge overlapping context windows
let mut windows: Vec<(usize, usize)> = Vec::new();
for &line_idx in &hit_lines {
let start = line_idx.saturating_sub(context_lines);
let end = (line_idx + context_lines + 1).min(lines.len());
if let Some(last) = windows.last_mut() {
if start <= last.1 {
last.1 = end;
continue;
}
}
windows.push((start, end));
}
let snippets: Vec<serde_json::Value> = windows
.iter()
.map(|(start, end)| {
let snippet: Vec<String> = lines[*start..*end]
.iter()
.map(|l| l.to_string())
.collect();
serde_json::json!({
"line_start": start + 1,
"line_end": end,
"snippet": snippet.join("\n"),
})
})
.collect();
matches.push(serde_json::json!({
"path": entry_path,
"hit_count": hit_lines.len(),
"snippets": snippets,
}));
}
}
}
_ => {}
}
}
}
Ok(serde_json::json!({
"keyword": keyword,
"matched_files": matched_files,
"total_hits": total_hits,
"results": matches,
}))
}
/// Tool: repo_readme — get README content
async fn repo_readme_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let tree = head_tree(&domain)?;
// Try common README filenames
let candidates = ["README.md", "README.MD", "README.markdown", "README.rst", "README.txt", "README"];
let mut found = None;
for candidate in &candidates {
if let Ok(entry) = tree.get_path(std::path::Path::new(candidate)) {
if let Ok(blob) = entry.to_object(repo).and_then(|o| o.peel_to_blob()) {
let content = String::from_utf8_lossy(blob.content()).to_string();
found = Some((candidate.to_string(), content));
break;
}
}
}
match found {
Some((filename, content)) => Ok(serde_json::json!({
"filename": filename,
"content": content,
"size": content.len(),
})),
None => Ok(serde_json::json!({
"filename": null,
"content": null,
"error": "No README file found in repository root",
})),
}
}
/// Tool: repo_commit_log — filtered commit history
async fn repo_commit_log_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let author = p.get("author").and_then(|v| v.as_str());
let keyword = p.get("keyword").and_then(|v| v.as_str());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let head_oid = head_oid(&domain)?;
// Fetch extra to allow for filtering
let fetch_limit = if author.is_some() || keyword.is_some() {
limit.saturating_mul(5).max(200)
} else {
limit
};
let commits = domain.commit_log(Some(&head_oid), 0, fetch_limit).map_err(|e| e.to_string())?;
let keyword_lower = keyword.map(|k| k.to_lowercase());
let author_lower = author.map(|a| a.to_lowercase());
let result: Vec<serde_json::Value> = commits
.iter()
.filter(|c| {
if let Some(ref al) = author_lower {
if !c.author.name.to_lowercase().contains(al) {
return false;
}
}
if let Some(ref kl) = keyword_lower {
if !c.message.to_lowercase().contains(kl) {
return false;
}
}
true
})
.take(limit)
.map(|c| {
let oid = c.oid.to_string();
serde_json::json!({
"oid": oid,
"short_oid": oid.get(..7).unwrap_or(&oid),
"summary": c.summary,
"author": c.author.name,
"author_email": c.author.email,
"time": c.author.time_secs,
})
})
.collect();
Ok(serde_json::json!({
"total": result.len(),
"commits": result,
}))
}
/// Tool: repo_contributors — contributor statistics
async fn repo_contributors_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let head_oid = head_oid(&domain)?;
// Walk all commits (up to 10000)
let commits = domain.commit_log(Some(&head_oid), 0, 10000).map_err(|e| e.to_string())?;
// Aggregate by author email (more reliable than name)
let mut authors: HashMap<String, serde_json::Value> = HashMap::new();
for c in &commits {
let key = c.author.email.clone();
let entry = authors.entry(key).or_insert_with(|| {
serde_json::json!({
"name": c.author.name,
"email": c.author.email,
"commit_count": 0u64,
"first_commit_time": c.author.time_secs,
"last_commit_time": c.author.time_secs,
})
});
entry["commit_count"] = serde_json::json!(
entry["commit_count"].as_u64().unwrap_or(0) + 1
);
let t = c.author.time_secs;
if t < entry["first_commit_time"].as_i64().unwrap_or(i64::MAX) {
entry["first_commit_time"] = serde_json::json!(t);
}
if t > entry["last_commit_time"].as_i64().unwrap_or(0) {
entry["last_commit_time"] = serde_json::json!(t);
}
}
let mut contributors: Vec<serde_json::Value> = authors.into_values().collect();
contributors.sort_by(|a, b| {
b["commit_count"].as_u64().unwrap_or(0)
.cmp(&a["commit_count"].as_u64().unwrap_or(0))
});
if limit > 0 {
contributors.truncate(limit);
}
Ok(serde_json::json!({
"total_contributors": contributors.len(),
"contributors": contributors,
}))
}
/// Tool: repo_diff_summary — change summary between two revisions
async fn repo_diff_summary_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = serde_json::from_value(args).map_err(|e| e.to_string())?;
let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?;
let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?;
let from_rev = p.get("from_rev").and_then(|v| v.as_str()).ok_or("missing from_rev")?;
let to_rev = p.get("to_rev").and_then(|v| v.as_str()).unwrap_or("HEAD");
let domain = ctx.open_repo(project_name, repo_name).await?;
let repo = domain.repo();
let from_oid = resolve_commit_oid(&domain, from_rev)?;
let to_oid = resolve_commit_oid(&domain, to_rev)?;
let from_commit = repo.find_commit(from_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("from_rev not found: {e}"))?;
let to_commit = repo.find_commit(to_oid.to_oid().map_err(|e| e.to_string())?)
.map_err(|e| format!("to_rev not found: {e}"))?;
let from_tree = from_commit.tree().map_err(|e| e.to_string())?;
let to_tree = to_commit.tree().map_err(|e| e.to_string())?;
let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&to_tree), None)
.map_err(|e| e.to_string())?;
let stats = diff.stats().map_err(|e| e.to_string())?;
let files_changed = stats.files_changed();
let insertions = stats.insertions();
let deletions = stats.deletions();
// Collect per-file stats
let mut files: Vec<serde_json::Value> = Vec::new();
for i in 0..diff.deltas().len() {
let delta = diff.deltas().nth(i);
if let Some(d) = delta {
let old_path = d.old_file().path().map(|p| p.to_string_lossy().to_string());
let new_path = d.new_file().path().map(|p| p.to_string_lossy().to_string());
let status = match d.status() {
git2::Delta::Added => "added",
git2::Delta::Deleted => "deleted",
git2::Delta::Modified => "modified",
git2::Delta::Renamed => "renamed",
git2::Delta::Copied => "copied",
_ => "other",
};
files.push(serde_json::json!({
"old_path": old_path,
"new_path": new_path,
"status": status,
}));
}
}
Ok(serde_json::json!({
"from": from_rev,
"to": to_rev,
"files_changed": files_changed,
"insertions": insertions,
"deletions": deletions,
"files": files,
}))
}
// ── Registration ───────────────────────────────────────────────────────────────
macro_rules! param {
($name:expr, $type:expr, $desc:expr, $required:expr) => {
(
$name.into(),
ToolParam {
name: $name.into(),
param_type: $type.into(),
description: Some($desc.into()),
required: $required,
properties: None,
items: None,
},
)
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
// repo_search
registry.register(
ToolDefinition::new("repo_search")
.description("Search all files in a repository for a keyword (case-insensitive). Returns matching file paths, hit counts, and context snippets. Skips binary files and generated directories. Use this to find where a function, variable, or concept is defined or used.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("keyword", "string", "Search keyword (case-insensitive)", true),
param!("context_lines", "integer", "Number of context lines around each match (default: 2)", false),
param!("max_results", "integer", "Maximum number of matching files to return (default: 50)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "keyword".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_search_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_readme
registry.register(
ToolDefinition::new("repo_readme")
.description("Read the README file from a repository. Automatically finds README.md, README.markdown, README.rst, README.txt, or README. Returns the full content. Use this as the first step to understand what a project is about.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_readme_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_commit_log
registry.register(
ToolDefinition::new("repo_commit_log")
.description("Get commit history with optional filters. Filter by author name (partial match), keyword in commit message, or limit the number of results. Use this to understand recent activity, find who made specific changes, or trace feature development.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("author", "string", "Filter by author name (partial match, case-insensitive)", false),
param!("keyword", "string", "Filter by keyword in commit message (case-insensitive)", false),
param!("limit", "integer", "Maximum number of commits to return (default: 20)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_commit_log_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_contributors
registry.register(
ToolDefinition::new("repo_contributors")
.description("List repository contributors sorted by commit count. Shows each contributor's name, email, commit count, and first/last commit timestamps. Use this to understand who is involved in a project and their contribution levels.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("limit", "integer", "Maximum number of contributors to return (0 = all, default: 0)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_contributors_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
// repo_diff_summary
registry.register(
ToolDefinition::new("repo_diff_summary")
.description("Get a summary of changes between two revisions (commits, branches, or tags). Shows files changed, insertions, deletions, and per-file status (added/modified/deleted/renamed). Use this to understand what changed between versions.")
.parameters(ToolSchema {
schema_type: "object".into(),
properties: Some(HashMap::from([
param!("project_name", "string", "Project name (slug)", true),
param!("repo_name", "string", "Repository name", true),
param!("from_rev", "string", "Source revision (commit SHA, branch name, or tag)", true),
param!("to_rev", "string", "Target revision (default: HEAD)", false),
])),
required: Some(vec!["project_name".into(), "repo_name".into(), "from_rev".into()]),
}),
ToolHandler::new(|ctx, args| {
let gctx = super::ctx::GitToolCtx::new(ctx);
Box::pin(async move {
repo_diff_summary_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError)
})
}),
);
}

View File

@ -132,6 +132,65 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
}
}
/// Scan git tree objects for `SKILL.md` files (works for bare repos).
fn scan_skills_from_tree(
git_repo: &git2::Repository,
repo_id: &RepoId,
commit_sha: &str,
) -> Result<Vec<DiscoveredSkill>, String> {
let repo_id_prefix = &repo_id.to_string()[..8];
let head = git_repo.head().map_err(|e| format!("no HEAD: {e}"))?;
let tree = head.peel_to_tree().map_err(|e| format!("no tree: {e}"))?;
let mut discovered = Vec::new();
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => {
let dir_name = std::path::Path::new(&entry_path)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.filter(|s| !s.starts_with('.'));
let Some(dir_name) = dir_name else { continue };
let slug = format!("{}/{}", repo_id_prefix, dir_name);
if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) {
let raw = blob.content();
let blob_hash = git_blob_hash(raw);
let mut skill = parse_skill_content(&slug, raw);
skill.commit_sha = Some(commit_sha.to_string());
skill.blob_hash = Some(blob_hash);
discovered.push(skill);
}
}
_ => {}
}
}
}
Ok(discovered)
}
#[derive(Clone)]
pub struct HookMetaDataSync {
pub db: AppDatabase,
@ -275,15 +334,9 @@ impl HookMetaDataSync {
/// Best-effort — failures are logged but do not fail the sync.
pub async fn sync_skills(&self) {
let project_uid = self.repo.project;
let git_repo = self.domain.repo();
let repo_root = match self.domain.repo().workdir() {
Some(p) => p.to_path_buf(),
None => return,
};
let commit_sha = self
.domain
.repo()
let commit_sha = git_repo
.head()
.ok()
.and_then(|h| h.target())
@ -291,19 +344,35 @@ impl HookMetaDataSync {
.unwrap_or_default();
let repo_id = self.repo.id;
let discovered = match tokio::task::spawn_blocking(move || {
scan_skills_from_dir(&repo_root, &repo_id, &commit_sha)
})
.await
{
Ok(Ok(d)) => d,
Ok(Err(e)) => {
tracing::warn!("failed to scan skills directory error={}", e);
return;
let is_bare = git_repo.is_bare() || git_repo.workdir().is_none();
let discovered = if is_bare {
// Bare repo: scan git tree objects directly
let git_repo_ref = self.domain.repo();
match scan_skills_from_tree(git_repo_ref, &repo_id, &commit_sha) {
Ok(d) => d,
Err(e) => {
tracing::warn!("failed to scan skills from tree error={}", e);
return;
}
}
Err(e) => {
tracing::warn!("spawn_blocking join error error={}", e);
return;
} else {
// Normal repo: walk filesystem
let repo_root = git_repo.workdir().unwrap().to_path_buf();
match tokio::task::spawn_blocking(move || {
scan_skills_from_dir(&repo_root, &repo_id, &commit_sha)
})
.await
{
Ok(Ok(d)) => d,
Ok(Err(e)) => {
tracing::warn!("failed to scan skills directory error={}", e);
return;
}
Err(e) => {
tracing::warn!("spawn_blocking join error error={}", e);
return;
}
}
};

View File

@ -90,7 +90,7 @@ fn extract_frontmatter(raw: &str) -> (Option<&str>, &str) {
}
}
/// Recursively scan `repo_path` for `SKILL.md` files.
/// Recursively scan `repo_path` for `SKILL.md` files (filesystem walk, non-bare repos).
/// The skill slug is `{short_repo_id}/{parent_dir_name}` to ensure uniqueness across repos.
pub fn scan_repo_for_skills(
repo_path: &Path,
@ -135,8 +135,72 @@ pub fn scan_repo_for_skills(
Ok(discovered)
}
/// Scan git tree objects for `SKILL.md` files (works for bare repos).
/// Traverses the HEAD commit tree using libgit2, reading blob content from objects.
pub fn scan_repo_tree_for_skills(
git_repo: &Repository,
repo_id: Uuid,
) -> Result<Vec<DiscoveredSkill>, AppError> {
let repo_id_prefix = &repo_id.to_string()[..8];
let head = git_repo
.head()
.map_err(|e| AppError::InternalServerError(format!("no HEAD: {e}")))?;
let tree = head
.peel_to_tree()
.map_err(|e| AppError::InternalServerError(format!("no tree: {e}")))?;
let mut discovered = Vec::new();
// Stack: (tree, path_prefix relative to root)
let mut stack: Vec<(git2::Tree<'_>, String)> = vec![(tree, String::new())];
while let Some((current_tree, prefix)) = stack.pop() {
for entry in current_tree.iter() {
let name = match entry.name() {
Some(n) => n,
None => continue,
};
let entry_path = if prefix.is_empty() {
name.to_string()
} else {
format!("{}/{}", prefix, name)
};
match entry.kind() {
Some(git2::ObjectType::Tree) => {
if !name.starts_with('.') {
if let Ok(subtree) = entry.to_object(git_repo).and_then(|o| o.peel_to_tree()) {
stack.push((subtree, entry_path));
}
}
}
Some(git2::ObjectType::Blob) if name.to_lowercase() == "skill.md" => {
// Derive skill name from parent directory
let dir_name = std::path::Path::new(&entry_path)
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.filter(|s| !s.starts_with('.'));
let Some(dir_name) = dir_name else { continue };
let slug = format!("{}/{}", repo_id_prefix, dir_name);
if let Ok(blob) = entry.to_object(git_repo).and_then(|o| o.peel_to_blob()) {
let raw = blob.content();
let blob_hash = git_blob_hash(raw);
let mut skill = parse_skill_file(&slug, &String::from_utf8_lossy(raw));
skill.blob_hash = Some(blob_hash);
discovered.push(skill);
}
}
_ => {}
}
}
}
Ok(discovered)
}
/// Scan a git2::Repository for skills and upsert them into the database.
/// Called from the git hook sync path.
/// Uses filesystem walk for normal repos, git tree traversal for bare repos.
pub async fn scan_and_sync_skills(
db: &db::database::AppDatabase,
project_uuid: Uuid,
@ -156,10 +220,21 @@ pub async fn scan_and_sync_skills(
}
};
let workdir = git_repo.workdir().map(|p| p.to_path_buf()).unwrap_or_else(|| Path::new(&repo.storage_path).to_path_buf());
let commit_sha = git_repo.head().ok().and_then(|h| h.target()).map(|oid| oid.to_string());
let mut discovered = scan_repo_for_skills(&workdir, repo.id)?;
// For bare repos (no workdir), scan git tree objects directly
let mut discovered = if git_repo.is_bare() || git_repo.workdir().is_none() {
match scan_repo_tree_for_skills(&git_repo, repo.id) {
Ok(skills) => skills,
Err(e) => {
tracing::warn!("tree scan failed for repo {}: {:?}", repo.storage_path, e);
vec![]
}
}
} else {
let workdir = git_repo.workdir().unwrap();
scan_repo_for_skills(workdir, repo.id)?
};
// Fill in commit_sha for discovered skills
for skill in &mut discovered {

View File

@ -498,7 +498,7 @@ export function RoomProvider({
),
);
streamingTimersRef.current.delete(msgId);
}, 60000);
}, 120000);
streamingTimersRef.current.set(msgId, timer);
}, []);