mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Repo summary generation
This commit is contained in:
parent
cae34e0c86
commit
ce9937f1dd
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -100,7 +100,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "exoshell"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "exoshell"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
93
src/app.rs
93
src/app.rs
|
|
@ -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()));
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
}
|
||||
|
|
|
|||
263
src/project.rs
263
src/project.rs
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user