Repo summary generation

This commit is contained in:
K. Hodges 2026-06-10 03:52:32 -07:00
parent cae34e0c86
commit ce9937f1dd
9 changed files with 365 additions and 9 deletions

2
Cargo.lock generated
View File

@ -100,7 +100,7 @@ dependencies = [
[[package]]
name = "exoshell"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"async-trait",
"futures-util",

View File

@ -1,6 +1,6 @@
[package]
name = "exoshell"
version = "0.8.0"
version = "0.9.0"
edition = "2024"
license = "GPL-3.0-or-later"

View File

@ -30,7 +30,7 @@ The current implementation supports the first shell-adjacent model chat mileston
The codebase also contains the Phase 1.5 context engine foundation: context entries, provenance metadata, priority and size estimates, a session context store, provider registry, default manual/file/command-output/stdin/directory-summary providers, context REPL commands, deterministic pruning, budget checks, transcript events, startup context flags, piped stdin import, and prompt-context rendering.
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, initial Git-native project detection, attachable Git status context, attachable Git diff context, and attachable recent commit context.
The active milestone is Phase 3. The current implementation adds stance selection, explicit prompt assembly, visible prompt/context budget estimates, command suggestion IDs, simple risky-command warnings, command copy/explain/discard actions, a plain terminal session panel, Phase 2 help text, configurable model routing, Git-native project detection, lightweight project scans, attachable Git status context, attachable Git diff context, and attachable recent commit context.
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).

View File

@ -203,6 +203,13 @@ Inspect detected Git project state:
exo> /project
```
Preview or attach a lightweight project summary:
```text
exo> /project scan --preview
exo> /project scan
```
Attach current Git branch and working tree state as explicit context:
```text

View File

@ -107,3 +107,4 @@ Historical codenames should be tracked in docs/versioning.md below
* 0.6.0 status-satellite
* 0.7.0 diff-lantern
* 0.8.0 commit-oracle
* 0.9.0 summary-relay

View File

@ -5,13 +5,16 @@ use std::time::Duration;
use crate::commands::{CommandSuggestion, parse_command_suggestions_with_policy};
use crate::config::Config;
use crate::context::{
ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest,
SessionContextStore, budget_warning, prune_context, register_default_context_providers,
render_context_details, render_context_list, render_context_stats,
ContextEntry, ContextError, ContextKind, ContextOrigin, ContextPriority, ContextProvenance,
ContextProviderRegistry, ContextProviderRequest, SessionContextStore, budget_warning,
prune_context, register_default_context_providers, render_context_details, render_context_list,
render_context_stats,
};
use crate::formatting::render_assistant_output_with_policy;
use crate::keybindings::render_keybindings;
use crate::project::{ProjectError, detect_project, render_project_status};
use crate::project::{
ProjectError, detect_project, render_project_status, render_project_summary, summarize_project,
};
use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
use crate::repl::ReplError;
@ -111,6 +114,10 @@ impl App {
return self.render_project();
}
if trimmed == "/project scan" || trimmed == "/project scan --preview" {
return self.project_scan(trimmed == "/project scan --preview");
}
if trimmed == "/help" {
return Ok(help_overview().into());
}
@ -342,6 +349,39 @@ impl App {
)
}
pub fn project_scan(&mut self, preview: bool) -> Result<String, AppError> {
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
path: PathBuf::from("."),
error: error.to_string(),
})?;
let summary = summarize_project(&cwd, self.config.project.root.as_deref())?;
let rendered = render_project_summary(&summary);
if preview {
return Ok(rendered);
}
let mut provenance = ContextProvenance::new(ContextOrigin::Generated);
provenance.source_path = Some(summary.root.clone());
provenance
.provider_details
.insert("branch".into(), summary.branch.clone());
provenance
.provider_details
.insert("files_seen".into(), summary.files_seen.to_string());
provenance
.provider_details
.insert("truncated".into(), summary.truncated.to_string());
let entry = ContextEntry::new(
"",
ContextKind::ProjectSummary,
"project summary",
provenance,
rendered,
)
.with_priority(ContextPriority::High);
Ok(format!("added {}", self.add_context_entry(entry)?))
}
fn project_context_path(&self) -> Result<PathBuf, AppError> {
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
path: PathBuf::from("."),
@ -551,6 +591,17 @@ impl App {
Ok(format!("added {} ({})", entry.id, entry.title))
}
fn add_context_entry(&mut self, entry: ContextEntry) -> Result<String, AppError> {
let id = self.context_store.add(entry);
let entry = self
.context_store
.get(&id)
.ok_or_else(|| ContextError::NotFound(id.clone()))?;
self.transcript
.record_context_event("add", entry, "added to session context");
Ok(format!("{} ({})", entry.id, entry.title))
}
fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<String, AppError> {
self.context_store.set_enabled(id, enabled)?;
let entry = self
@ -633,6 +684,7 @@ fn help_overview() -> &'static str {
"Commands:
/context list attached context
/project show detected Git project and branch
/project scan [--preview] summarize project or add summary context
/context stats show context and prompt budget estimates
/context show <id> inspect a context entry
/context enable|disable <id> control model inclusion
@ -663,7 +715,7 @@ fn help_topic(topic: &str) -> &'static str {
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
}
"project" => {
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch."
"Project detection walks upward from the current directory or configured project.root to find a Git repository. Use /project or /panel to inspect the detected root and branch. Use /project scan --preview to inspect a lightweight repository summary, or /project scan to add it as context."
}
"git" => {
"Use /add-git-status to attach current branch, staged files, modified files, and untracked files. Use /add-diff, /add-diff --staged, or /add-diff --staged <path> to attach read-only diff context. Use /add-commits, /add-commits --count 10, /add-commits --author=alice, or /add-commits src/app.rs to attach recent history."
@ -1133,6 +1185,37 @@ mod tests {
assert!(parse_git_commit_args("one two").is_err());
}
#[test]
fn project_scan_preview_and_context_add_use_summary() {
let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo");
std::fs::create_dir_all(repo.join(".git")).expect("git dir");
std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("head");
std::fs::create_dir_all(repo.join("src")).expect("src dir");
std::fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
std::fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
let mut config = test_config();
config.project.root = Some(repo);
let mut app = App::new(config, Box::new(NoopProvider));
let preview = app
.handle_command("/project scan --preview")
.expect("preview");
assert!(preview.contains("Project Summary"));
assert!(preview.contains("src/main.rs"));
assert_eq!(
app.handle_command("/project scan").expect("scan"),
"added ctx-001 (project summary)"
);
assert!(
app.handle_command("/context")
.expect("context")
.contains("project_summary")
);
}
#[tokio::test]
async fn over_budget_context_fails_before_provider_request() {
let seen = Arc::new(Mutex::new(Vec::new()));

View File

@ -71,6 +71,7 @@ pub enum ContextKind {
Note,
SearchResult,
NotebookEntry,
ProjectSummary,
Manual,
Unknown(String),
}
@ -88,6 +89,7 @@ impl fmt::Display for ContextKind {
Self::Note => formatter.write_str("note"),
Self::SearchResult => formatter.write_str("search_result"),
Self::NotebookEntry => formatter.write_str("notebook_entry"),
Self::ProjectSummary => formatter.write_str("project_summary"),
Self::Manual => formatter.write_str("manual"),
Self::Unknown(value) => write!(formatter, "unknown:{value}"),
}

View File

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
@ -62,6 +63,242 @@ pub fn render_project_status(project: Option<&ProjectInfo>) -> String {
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSummary {
pub root: PathBuf,
pub branch: String,
pub major_directories: Vec<String>,
pub languages: Vec<LanguageSummary>,
pub entry_points: Vec<String>,
pub build_files: Vec<String>,
pub files_seen: usize,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageSummary {
pub language: String,
pub files: usize,
}
pub fn summarize_project(
start: &Path,
root_override: Option<&Path>,
) -> Result<ProjectSummary, ProjectError> {
let project = detect_project(start, root_override)?.ok_or(ProjectError::NotDetected)?;
Ok(summarize_project_root(&project))
}
pub fn render_project_summary(summary: &ProjectSummary) -> String {
let mut rendered = String::new();
rendered.push_str(&format!(
"Project Summary\nroot: {}\n",
summary.root.display()
));
rendered.push_str(&format!("branch: {}\n", summary.branch));
rendered.push_str(&format!("files_seen: {}\n", summary.files_seen));
rendered.push_str(&format!("truncated: {}\n\n", summary.truncated));
rendered.push_str("major_directories:\n");
render_lines(&mut rendered, &summary.major_directories);
rendered.push_str("\nlanguages:\n");
if summary.languages.is_empty() {
rendered.push_str("- none\n");
} else {
for language in &summary.languages {
rendered.push_str(&format!(
"- {}: {} files\n",
language.language, language.files
));
}
}
rendered.push_str("\nentry_points:\n");
render_lines(&mut rendered, &summary.entry_points);
rendered.push_str("\nbuild_files:\n");
render_lines(&mut rendered, &summary.build_files);
rendered.trim_end().to_string()
}
fn summarize_project_root(project: &ProjectInfo) -> ProjectSummary {
const MAX_FILES: usize = 1_000;
let major_directories = major_directories(&project.root);
let mut languages = BTreeMap::<String, usize>::new();
let mut entry_points = Vec::new();
let mut build_files = Vec::new();
let mut files_seen = 0;
let mut truncated = false;
let mut stack = vec![project.root.clone()];
while let Some(path) = stack.pop() {
let Ok(entries) = fs::read_dir(&path) else {
continue;
};
let mut entries = entries.filter_map(Result::ok).collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let entry_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if is_noisy_project_path(&name) {
continue;
}
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry_path);
continue;
}
if !file_type.is_file() {
continue;
}
files_seen += 1;
if files_seen > MAX_FILES {
truncated = true;
break;
}
let relative = relative_display(&project.root, &entry_path);
if let Some(language) = language_for_path(&entry_path) {
*languages.entry(language.into()).or_insert(0) += 1;
}
if is_likely_entry_point(&relative) {
entry_points.push(relative.clone());
}
if is_build_file(&relative) {
build_files.push(relative);
}
}
if truncated {
break;
}
}
let mut languages = languages
.into_iter()
.map(|(language, files)| LanguageSummary { language, files })
.collect::<Vec<_>>();
languages.sort_by(|left, right| {
right
.files
.cmp(&left.files)
.then(left.language.cmp(&right.language))
});
entry_points.sort();
entry_points.dedup();
build_files.sort();
build_files.dedup();
ProjectSummary {
root: project.root.clone(),
branch: project.branch_label(),
major_directories,
languages,
entry_points,
build_files,
files_seen: files_seen.min(MAX_FILES),
truncated,
}
}
fn major_directories(root: &Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
let mut directories = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
if is_noisy_project_path(&name) || !entry.file_type().ok()?.is_dir() {
None
} else {
Some(format!("{name}/"))
}
})
.collect::<Vec<_>>();
directories.sort();
directories
}
fn render_lines(rendered: &mut String, lines: &[String]) {
if lines.is_empty() {
rendered.push_str("- none\n");
} else {
for line in lines {
rendered.push_str(&format!("- {line}\n"));
}
}
}
fn relative_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn is_noisy_project_path(name: &str) -> bool {
matches!(
name,
".git"
| "target"
| "node_modules"
| ".venv"
| "venv"
| "dist"
| "build"
| ".next"
| ".cache"
)
}
fn language_for_path(path: &Path) -> Option<&'static str> {
match path.extension()?.to_string_lossy().as_ref() {
"rs" => Some("Rust"),
"toml" => Some("TOML"),
"md" => Some("Markdown"),
"js" | "jsx" => Some("JavaScript"),
"ts" | "tsx" => Some("TypeScript"),
"py" => Some("Python"),
"ps1" => Some("PowerShell"),
"sh" | "bash" | "zsh" => Some("Shell"),
"json" => Some("JSON"),
"yaml" | "yml" => Some("YAML"),
_ => None,
}
}
fn is_likely_entry_point(relative: &str) -> bool {
matches!(
relative,
"src/main.rs"
| "src/lib.rs"
| "main.py"
| "app.py"
| "index.js"
| "index.ts"
| "src/index.js"
| "src/index.ts"
)
}
fn is_build_file(relative: &str) -> bool {
matches!(
relative,
"Cargo.toml"
| "package.json"
| "pyproject.toml"
| "Makefile"
| "justfile"
| "go.mod"
| "pom.xml"
| "build.gradle"
)
}
fn resolve_start(start: &Path, root_override: Option<&Path>) -> PathBuf {
match root_override {
Some(root) if root.is_absolute() => root.to_path_buf(),
@ -153,6 +390,8 @@ pub enum ProjectError {
Read { path: PathBuf, error: String },
#[error("invalid git file at {0}")]
InvalidGitFile(PathBuf),
#[error("project not detected")]
NotDetected,
}
#[cfg(test)]
@ -231,6 +470,30 @@ mod tests {
assert_eq!(project, None);
}
#[test]
fn summarizes_project_without_full_indexing() {
let tempdir = tempfile::tempdir().expect("tempdir");
let repo = tempdir.path().join("repo");
write_head(&repo, "main");
fs::create_dir_all(repo.join("src")).expect("src dir");
fs::create_dir_all(repo.join("target")).expect("target dir");
fs::write(repo.join("Cargo.toml"), "[package]\nname = \"demo\"\n").expect("cargo");
fs::write(repo.join("src").join("main.rs"), "fn main() {}\n").expect("main");
fs::write(repo.join("README.md"), "# demo\n").expect("readme");
fs::write(repo.join("target").join("ignored.rs"), "ignored").expect("ignored");
let summary = summarize_project(&repo, None).expect("summary");
let rendered = render_project_summary(&summary);
assert_eq!(summary.branch, "main");
assert!(summary.major_directories.contains(&"src/".to_string()));
assert!(!summary.major_directories.contains(&"target/".to_string()));
assert!(summary.entry_points.contains(&"src/main.rs".to_string()));
assert!(summary.build_files.contains(&"Cargo.toml".to_string()));
assert!(rendered.contains("languages:"));
assert!(rendered.contains("Rust"));
}
fn write_head(root: &Path, branch: &str) {
let git_dir = root.join(".git");
fs::create_dir_all(&git_dir).expect("git dir");

View File

@ -45,7 +45,7 @@ impl Repl {
}
if input.starts_with("/context")
|| input == "/project"
|| input.starts_with("/project")
|| input.starts_with("/stance")
|| input.starts_with("/copy ")
|| input.starts_with("/explain ")