Lightweight project inventory

This commit is contained in:
K. Hodges 2026-06-10 03:37:46 -07:00
parent b6db491caa
commit cae34e0c86
8 changed files with 243 additions and 6 deletions

2
Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package] [package]
name = "exoshell" name = "exoshell"
version = "0.7.0" version = "0.8.0"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" 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 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, and attachable Git diff 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, initial Git-native project detection, 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). The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).

View File

@ -217,6 +217,14 @@ exo> /add-diff --staged
exo> /add-diff --staged src/app.rs exo> /add-diff --staged src/app.rs
``` ```
Attach recent commit history:
```text
exo> /add-commits
exo> /add-commits --count 10
exo> /add-commits --author=alice src/app.rs
```
Ask a question: Ask a question:
```text ```text

View File

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

View File

@ -242,6 +242,11 @@ impl App {
return self.add_git_diff_context(args); return self.add_git_diff_context(args);
} }
if trimmed == "/add-commits" || trimmed.starts_with("/add-commits ") {
let args = trimmed.strip_prefix("/add-commits").unwrap_or("").trim();
return self.add_git_commit_context(args);
}
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into()) Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
} }
@ -324,6 +329,19 @@ impl App {
) )
} }
pub fn add_git_commit_context(&mut self, input: &str) -> Result<String, AppError> {
let options = parse_git_commit_args(input)?;
let path = self.project_context_path()?;
self.add_context(
"git_commits",
ContextProviderRequest {
path: Some(path),
provider_options: options,
..ContextProviderRequest::default()
},
)
}
fn project_context_path(&self) -> Result<PathBuf, AppError> { fn project_context_path(&self) -> Result<PathBuf, AppError> {
let cwd = std::env::current_dir().map_err(|error| ProjectError::Read { let cwd = std::env::current_dir().map_err(|error| ProjectError::Read {
path: PathBuf::from("."), path: PathBuf::from("."),
@ -625,6 +643,7 @@ fn help_overview() -> &'static str {
/add-dir <path> attach a shallow directory summary /add-dir <path> attach a shallow directory summary
/add-git-status attach current Git branch and status /add-git-status attach current Git branch and status
/add-diff [--staged] [path] attach unstaged or staged Git diff context /add-diff [--staged] [path] attach unstaged or staged Git diff context
/add-commits [options] [path] attach recent Git commit history
/add-output paste command output as explicit context /add-output paste command output as explicit context
/stance [name] show or set operator, audit, teach, or quiet /stance [name] show or set operator, audit, teach, or quiet
/copy <cmd-id> print a suggested command; does not execute it /copy <cmd-id> print a suggested command; does not execute it
@ -647,7 +666,7 @@ fn help_topic(topic: &str) -> &'static str {
"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."
} }
"git" => { "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-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."
} }
"stance" => { "stance" => {
"Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings." "Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings."
@ -684,6 +703,36 @@ fn parse_git_diff_args(input: &str) -> Result<(&'static str, Option<String>), Co
Ok((mode, file)) Ok((mode, file))
} }
fn parse_git_commit_args(input: &str) -> Result<HashMap<String, String>, ContextError> {
let mut options = HashMap::new();
let mut parts = input.split_whitespace();
while let Some(part) = parts.next() {
if part == "--count" {
let count = parts
.next()
.ok_or_else(|| ContextError::InvalidInput("--count requires a value".into()))?;
options.insert("count".into(), count.to_string());
} else if let Some(author) = part.strip_prefix("--author=") {
if author.trim().is_empty() {
return Err(ContextError::InvalidInput(
"--author requires a non-empty value".into(),
));
}
options.insert("author".into(), author.to_string());
} else if part.starts_with('-') {
return Err(ContextError::InvalidInput(format!(
"unknown /add-commits option: {part}"
)));
} else if options.insert("file".into(), part.to_string()).is_some() {
return Err(ContextError::InvalidInput(
"/add-commits accepts at most one path".into(),
));
}
}
Ok(options)
}
#[derive(Debug, Default, PartialEq, Eq)] #[derive(Debug, Default, PartialEq, Eq)]
pub struct CliOptions { pub struct CliOptions {
pub config_path: Option<PathBuf>, pub config_path: Option<PathBuf>,
@ -854,7 +903,8 @@ mod tests {
"stdin".to_string(), "stdin".to_string(),
"directory_summary".to_string(), "directory_summary".to_string(),
"git_status".to_string(), "git_status".to_string(),
"git_diff".to_string() "git_diff".to_string(),
"git_commits".to_string()
] ]
); );
assert_eq!(app.context_store.total_size().characters, 0); assert_eq!(app.context_store.total_size().characters, 0);
@ -1068,6 +1118,21 @@ mod tests {
assert!(parse_git_diff_args("one two").is_err()); assert!(parse_git_diff_args("one two").is_err());
} }
#[test]
fn parses_git_commit_arguments() {
assert!(parse_git_commit_args("").expect("default").is_empty());
let options =
parse_git_commit_args("--count 10 --author=alice src/app.rs").expect("commit args");
assert_eq!(options.get("count"), Some(&"10".to_string()));
assert_eq!(options.get("author"), Some(&"alice".to_string()));
assert_eq!(options.get("file"), Some(&"src/app.rs".to_string()));
assert!(parse_git_commit_args("--count").is_err());
assert!(parse_git_commit_args("--author=").is_err());
assert!(parse_git_commit_args("one two").is_err());
}
#[tokio::test] #[tokio::test]
async fn over_budget_context_fails_before_provider_request() { async fn over_budget_context_fails_before_provider_request() {
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));

View File

@ -65,6 +65,7 @@ pub enum ContextKind {
CommandOutput, CommandOutput,
DirectorySummary, DirectorySummary,
GitDiff, GitDiff,
GitHistory,
GitStatus, GitStatus,
Log, Log,
Note, Note,
@ -81,6 +82,7 @@ impl fmt::Display for ContextKind {
Self::CommandOutput => formatter.write_str("command_output"), Self::CommandOutput => formatter.write_str("command_output"),
Self::DirectorySummary => formatter.write_str("directory_summary"), Self::DirectorySummary => formatter.write_str("directory_summary"),
Self::GitDiff => formatter.write_str("git_diff"), Self::GitDiff => formatter.write_str("git_diff"),
Self::GitHistory => formatter.write_str("git_history"),
Self::GitStatus => formatter.write_str("git_status"), Self::GitStatus => formatter.write_str("git_status"),
Self::Log => formatter.write_str("log"), Self::Log => formatter.write_str("log"),
Self::Note => formatter.write_str("note"), Self::Note => formatter.write_str("note"),
@ -314,6 +316,7 @@ pub fn register_default_context_providers(
registry.register(Box::new(DirectorySummaryContextProvider::default()))?; registry.register(Box::new(DirectorySummaryContextProvider::default()))?;
registry.register(Box::new(GitStatusContextProvider))?; registry.register(Box::new(GitStatusContextProvider))?;
registry.register(Box::new(GitDiffContextProvider::default()))?; registry.register(Box::new(GitDiffContextProvider::default()))?;
registry.register(Box::new(GitCommitContextProvider))?;
Ok(()) Ok(())
} }
@ -602,6 +605,57 @@ impl ContextProvider for GitStatusContextProvider {
} }
} }
pub struct GitCommitContextProvider;
impl ContextProvider for GitCommitContextProvider {
fn metadata(&self) -> ContextProviderMetadata {
ContextProviderMetadata {
name: "git_commits".into(),
kind: ContextKind::GitHistory,
description: "captures recent Git commits and changed files".into(),
}
}
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
let path = request
.path
.or(request.cwd)
.unwrap_or_else(|| PathBuf::from("."));
let count = parse_git_commit_count(request.provider_options.get("count"))?;
let author = request.provider_options.get("author").cloned();
let file = request.provider_options.get("file").cloned();
let log = git_log_output(&path, count, author.as_deref(), file.as_deref())?;
let content = if log.trim().is_empty() {
"recent commits: none".to_string()
} else {
log
};
let mut provenance = ContextProvenance::new(ContextOrigin::Git);
provenance.source_path = Some(path);
provenance
.provider_details
.insert("count".into(), count.to_string());
provenance.provider_details.insert(
"author_filter".into(),
author.unwrap_or_else(|| "none".into()),
);
provenance
.provider_details
.insert("path_filter".into(), file.unwrap_or_else(|| "none".into()));
Ok(ContextEntry::new(
"",
ContextKind::GitHistory,
request
.title
.unwrap_or_else(|| format!("recent git commits ({count})")),
provenance,
content,
))
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct GitDiffContextProvider { pub struct GitDiffContextProvider {
pub max_characters: usize, pub max_characters: usize,
@ -761,6 +815,72 @@ fn render_path_list(rendered: &mut String, paths: &[String]) {
} }
} }
fn parse_git_commit_count(value: Option<&String>) -> Result<usize, ContextError> {
let Some(value) = value else {
return Ok(5);
};
let count = value.parse::<usize>().map_err(|_| {
ContextError::InvalidInput(format!(
"git commit count must be a positive integer: {value}"
))
})?;
if count == 0 || count > 100 {
return Err(ContextError::InvalidInput(
"git commit count must be between 1 and 100".into(),
));
}
Ok(count)
}
fn git_log_output(
path: &Path,
count: usize,
author: Option<&str>,
file: Option<&str>,
) -> Result<String, ContextError> {
let mut command = Command::new("git");
command
.arg("-C")
.arg(path)
.arg("log")
.arg(format!("--max-count={count}"))
.arg("--date=iso-strict")
.arg("--pretty=format:commit: %H%nshort: %h%nauthor: %an <%ae>%ndate: %ad%nsubject: %s")
.arg("--name-only");
if let Some(author) = author {
command.arg(format!("--author={author}"));
}
if let Some(file) = file {
command.arg("--").arg(file);
}
let output = command.output().map_err(|error| {
ContextError::InternalFailure(format!("failed to run git log: {error}"))
})?;
if output.status.success() {
return String::from_utf8(output.stdout).map_err(|error| {
ContextError::UnsupportedContent(format!("git log output was not valid UTF-8: {error}"))
});
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.contains("does not have any commits yet")
|| stderr.contains("your current branch") && stderr.contains("no commits")
{
return Ok(String::new());
}
Err(ContextError::InternalFailure(format!(
"git log failed for {}: {}",
path.display(),
if stderr.is_empty() {
output.status.to_string()
} else {
stderr
}
)))
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct TruncatedContent { struct TruncatedContent {
content: String, content: String,
@ -1659,7 +1779,8 @@ mod tests {
"stdin".to_string(), "stdin".to_string(),
"directory_summary".to_string(), "directory_summary".to_string(),
"git_status".to_string(), "git_status".to_string(),
"git_diff".to_string() "git_diff".to_string(),
"git_commits".to_string()
] ]
); );
} }
@ -1717,6 +1838,46 @@ mod tests {
); );
} }
#[test]
fn git_commit_provider_metadata_is_git_context() {
let metadata = GitCommitContextProvider.metadata();
assert_eq!(metadata.name, "git_commits");
assert_eq!(metadata.kind, ContextKind::GitHistory);
}
#[test]
fn git_commit_count_is_bounded() {
assert_eq!(parse_git_commit_count(None).expect("default"), 5);
assert_eq!(
parse_git_commit_count(Some(&"10".to_string())).expect("count"),
10
);
assert!(parse_git_commit_count(Some(&"0".to_string())).is_err());
assert!(parse_git_commit_count(Some(&"101".to_string())).is_err());
assert!(parse_git_commit_count(Some(&"abc".to_string())).is_err());
}
#[test]
fn git_commit_provider_handles_repositories_without_commits() {
let tempdir = tempfile::tempdir().expect("tempdir");
std::process::Command::new("git")
.arg("init")
.arg(tempdir.path())
.output()
.expect("git init");
let entry = GitCommitContextProvider
.collect(ContextProviderRequest {
path: Some(tempdir.path().to_path_buf()),
..ContextProviderRequest::default()
})
.expect("empty git log");
assert_eq!(entry.kind, ContextKind::GitHistory);
assert_eq!(entry.content, "recent commits: none");
}
#[test] #[test]
fn manual_provider_rejects_empty_context() { fn manual_provider_rejects_empty_context() {
let error = ManualContextProvider let error = ManualContextProvider

View File

@ -59,6 +59,8 @@ impl Repl {
|| input == "/add-git-status" || input == "/add-git-status"
|| input == "/add-diff" || input == "/add-diff"
|| input.starts_with("/add-diff ") || input.starts_with("/add-diff ")
|| input == "/add-commits"
|| input.starts_with("/add-commits ")
{ {
match self.app.handle_command(&input) { match self.app.handle_command(&input) {
Ok(message) => println!("{message}"), Ok(message) => println!("{message}"),