diff --git a/Cargo.lock b/Cargo.lock index d111de4..bb98223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.8.0" +version = "0.9.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index c20bc2c..8549422 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 24a5838..c0a209e 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/quickstart.md b/docs/quickstart.md index b78e226..7687637 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 diff --git a/docs/versioning.md b/docs/versioning.md index 0bf6cc0..6abd254 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -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 diff --git a/src/app.rs b/src/app.rs index c6277a6..4b33104 100644 --- a/src/app.rs +++ b/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 { + 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 { 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 { + 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 { 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 inspect a context entry /context enable|disable 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 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())); diff --git a/src/context.rs b/src/context.rs index a471d4c..556d733 100644 --- a/src/context.rs +++ b/src/context.rs @@ -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}"), } diff --git a/src/project.rs b/src/project.rs index 38d741f..0acd90f 100644 --- a/src/project.rs +++ b/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, + pub languages: Vec, + pub entry_points: Vec, + pub build_files: Vec, + 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 { + 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::::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::>(); + 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::>(); + 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 { + 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::>(); + 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"); diff --git a/src/repl.rs b/src/repl.rs index cf8f9c1..c5499eb 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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 ")