attachable repository search context

attachable repository search context
This commit is contained in:
K. Hodges 2026-06-10 04:12:11 -07:00
parent ce9937f1dd
commit 3155ca76cd
8 changed files with 449 additions and 6 deletions

2
Cargo.lock generated
View File

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

View File

@ -1,6 +1,6 @@
[package]
name = "exoshell"
version = "0.9.0"
version = "0.10.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, Git-native project detection, lightweight project scans, 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, attachable recent commit context, and attachable repository search context.
The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks).

View File

@ -232,6 +232,13 @@ exo> /add-commits --count 10
exo> /add-commits --author=alice src/app.rs
```
Attach repository search results:
```text
exo> /search ContextProvider
exo> /search-path Cargo.toml
```
Ask a question:
```text

View File

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

View File

@ -254,6 +254,14 @@ impl App {
return self.add_git_commit_context(args);
}
if let Some(query) = trimmed.strip_prefix("/search-path ") {
return self.add_repository_search_context("path", query.trim());
}
if let Some(query) = trimmed.strip_prefix("/search ") {
return self.add_repository_search_context("text", query.trim());
}
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
}
@ -349,6 +357,29 @@ impl App {
)
}
pub fn add_repository_search_context(
&mut self,
mode: &str,
query: &str,
) -> Result<String, AppError> {
if query.trim().is_empty() {
return Err(ContextError::InvalidInput("search query cannot be empty".into()).into());
}
let mut provider_options = HashMap::new();
provider_options.insert("mode".into(), mode.to_string());
provider_options.insert("query".into(), query.trim().to_string());
let path = self.project_context_path()?;
self.add_context(
"repo_search",
ContextProviderRequest {
path: Some(path),
provider_options,
..ContextProviderRequest::default()
},
)
}
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("."),
@ -696,6 +727,8 @@ fn help_overview() -> &'static str {
/add-git-status attach current Git branch and status
/add-diff [--staged] [path] attach unstaged or staged Git diff context
/add-commits [options] [path] attach recent Git commit history
/search <query> attach repository text search results
/search-path <query> attach repository path search results
/add-output paste command output as explicit context
/stance [name] show or set operator, audit, teach, or quiet
/copy <cmd-id> print a suggested command; does not execute it
@ -720,6 +753,9 @@ fn help_topic(topic: &str) -> &'static str {
"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."
}
"search" => {
"Use /search <query> to attach repository text matches with file, line, and column locations. Use /search-path <query> to attach matching repository paths. Text search uses ripgrep when available and falls back to a bounded repository walk."
}
"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."
}
@ -730,7 +766,7 @@ fn help_topic(topic: &str) -> &'static str {
"The current line REPL does not install advanced terminal keybindings. Use /keys to see the predictable slash-command fallbacks for copy, explain, discard, context, and stance actions."
}
_ => {
"Unknown help topic. Try /help context, /help project, /help git, /help stance, /help commands, or /help keys."
"Unknown help topic. Try /help context, /help project, /help git, /help search, /help stance, /help commands, or /help keys."
}
}
}
@ -888,6 +924,7 @@ mod tests {
use crate::config::{
CommandConfig, InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig,
};
use std::fs;
use std::sync::{Arc, Mutex};
#[test]
@ -956,7 +993,8 @@ mod tests {
"directory_summary".to_string(),
"git_status".to_string(),
"git_diff".to_string(),
"git_commits".to_string()
"git_commits".to_string(),
"repo_search".to_string()
]
);
assert_eq!(app.context_store.total_size().characters, 0);
@ -1012,6 +1050,29 @@ mod tests {
);
}
#[test]
fn search_path_command_adds_repository_search_context() {
let tempdir = tempfile::tempdir().expect("tempdir");
let src = tempdir.path().join("src");
fs::create_dir(&src).expect("create src");
fs::write(src.join("app.rs"), "fn main() {}\n").expect("write app");
let mut config = test_config();
config.project.root = Some(tempdir.path().to_path_buf());
let mut app = App::new(config, Box::new(NoopProvider));
let message = app
.handle_command("/search-path APP")
.expect("search path command");
assert_eq!(message, "added ctx-001 (repository path search: APP)");
assert!(
app.handle_command("/context show ctx-001")
.expect("show")
.contains("src/app.rs")
);
}
#[test]
fn stance_command_shows_and_changes_current_stance() {
let mut app = App::new(test_config(), Box::new(NoopProvider));
@ -1139,6 +1200,16 @@ mod tests {
.expect("help git")
.contains("/add-diff --staged")
);
assert!(
app.handle_command("/help")
.expect("help")
.contains("/search <query>")
);
assert!(
app.handle_command("/help search")
.expect("help search")
.contains("ripgrep")
);
assert!(
app.handle_command("/help commands")
.expect("help")

View File

@ -319,6 +319,7 @@ pub fn register_default_context_providers(
registry.register(Box::new(GitStatusContextProvider))?;
registry.register(Box::new(GitDiffContextProvider::default()))?;
registry.register(Box::new(GitCommitContextProvider))?;
registry.register(Box::new(RepositorySearchContextProvider::default()))?;
Ok(())
}
@ -658,6 +659,113 @@ impl ContextProvider for GitCommitContextProvider {
}
}
#[derive(Debug, Clone)]
pub struct RepositorySearchContextProvider {
pub max_results: usize,
pub max_file_bytes: u64,
}
impl Default for RepositorySearchContextProvider {
fn default() -> Self {
Self {
max_results: 100,
max_file_bytes: 256 * 1024,
}
}
}
impl ContextProvider for RepositorySearchContextProvider {
fn metadata(&self) -> ContextProviderMetadata {
ContextProviderMetadata {
name: "repo_search".into(),
kind: ContextKind::SearchResult,
description: "searches repository text or paths as explicit context".into(),
}
}
fn collect(&self, request: ContextProviderRequest) -> Result<ContextEntry, ContextError> {
let path = request
.path
.or(request.cwd)
.unwrap_or_else(|| PathBuf::from("."));
let query = request
.provider_options
.get("query")
.ok_or_else(|| ContextError::InvalidInput("search query is required".into()))?
.trim()
.to_string();
if query.is_empty() {
return Err(ContextError::InvalidInput(
"search query cannot be empty".into(),
));
}
let mode = request
.provider_options
.get("mode")
.map(String::as_str)
.unwrap_or("text");
let search = match mode {
"text" => search_text(&path, &query, self.max_results, self.max_file_bytes)?,
"path" => search_paths(&path, &query, self.max_results)?,
other => {
return Err(ContextError::InvalidInput(format!(
"unsupported search mode '{other}', expected text or path"
)));
}
};
let mut provenance = ContextProvenance::new(ContextOrigin::Search);
provenance.source_path = Some(path);
provenance
.provider_details
.insert("mode".into(), mode.to_string());
provenance
.provider_details
.insert("query".into(), query.clone());
provenance
.provider_details
.insert("engine".into(), search.engine.to_string());
provenance
.provider_details
.insert("result_count".into(), search.results.len().to_string());
provenance
.provider_details
.insert("max_results".into(), self.max_results.to_string());
provenance
.provider_details
.insert("truncated".into(), search.truncated.to_string());
let content = if search.results.is_empty() {
format!(
"mode: {mode}\nquery: {query}\nengine: {}\ntruncated: {}\nresults: none",
search.engine, search.truncated
)
} else {
format!(
"mode: {mode}\nquery: {query}\nengine: {}\ntruncated: {}\nresults:\n{}",
search.engine,
search.truncated,
search
.results
.iter()
.map(|result| format!("- {result}"))
.collect::<Vec<_>>()
.join("\n")
)
};
Ok(ContextEntry::new(
"",
ContextKind::SearchResult,
request
.title
.unwrap_or_else(|| format!("repository {mode} search: {query}")),
provenance,
content,
))
}
}
#[derive(Debug, Clone)]
pub struct GitDiffContextProvider {
pub max_characters: usize,
@ -883,6 +991,191 @@ fn git_log_output(
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SearchCollection {
engine: SearchEngine,
results: Vec<String>,
truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SearchEngine {
Ripgrep,
Fallback,
}
impl fmt::Display for SearchEngine {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Ripgrep => formatter.write_str("ripgrep"),
Self::Fallback => formatter.write_str("fallback"),
}
}
}
fn search_text(
root: &Path,
query: &str,
max_results: usize,
max_file_bytes: u64,
) -> Result<SearchCollection, ContextError> {
let rg_output = Command::new("rg")
.arg("--line-number")
.arg("--column")
.arg("--no-heading")
.arg("--color=never")
.arg("--fixed-strings")
.arg("--")
.arg(query)
.arg(root)
.output();
if let Ok(output) = rg_output {
let usable_output = output.status.success() || output.status.code() == Some(1);
if usable_output {
let stdout = String::from_utf8(output.stdout).map_err(|error| {
ContextError::UnsupportedContent(format!("rg output was not valid UTF-8: {error}"))
})?;
let scan_limit = max_results.saturating_add(1);
let all_results: Vec<String> = stdout
.lines()
.take(scan_limit)
.map(|line| normalize_search_line(root, line))
.collect();
let truncated = all_results.len() > max_results;
return Ok(SearchCollection {
engine: SearchEngine::Ripgrep,
results: all_results.into_iter().take(max_results).collect(),
truncated,
});
}
}
fallback_text_search(root, query, max_results, max_file_bytes)
}
fn search_paths(
root: &Path,
query: &str,
max_results: usize,
) -> Result<SearchCollection, ContextError> {
let mut results = Vec::new();
let needle = query.to_lowercase();
let scan_limit = max_results.saturating_add(1);
visit_files(root, scan_limit, &mut |path| {
let relative = relative_path(root, path);
if relative.to_lowercase().contains(&needle) {
results.push(relative);
}
Ok(results.len() < scan_limit)
})?;
let truncated = results.len() > max_results;
results.truncate(max_results);
Ok(SearchCollection {
engine: SearchEngine::Fallback,
results,
truncated,
})
}
fn fallback_text_search(
root: &Path,
query: &str,
max_results: usize,
max_file_bytes: u64,
) -> Result<SearchCollection, ContextError> {
let mut results = Vec::new();
let needle = query.to_lowercase();
let scan_limit = max_results.saturating_add(1);
visit_files(root, scan_limit, &mut |path| {
let Ok(metadata) = fs::metadata(path) else {
return Ok(true);
};
if metadata.len() > max_file_bytes {
return Ok(true);
}
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(_) => return Ok(true),
};
if bytes.contains(&0) {
return Ok(true);
}
let Ok(content) = String::from_utf8(bytes) else {
return Ok(true);
};
for (index, line) in content.lines().enumerate() {
let lower_line = line.to_lowercase();
if let Some(column_index) = lower_line.find(&needle) {
results.push(format!(
"{}:{}:{}:{}",
relative_path(root, path),
index + 1,
column_index + 1,
line.trim()
));
if results.len() >= scan_limit {
break;
}
}
}
Ok(results.len() < scan_limit)
})?;
let truncated = results.len() > max_results;
results.truncate(max_results);
Ok(SearchCollection {
engine: SearchEngine::Fallback,
results,
truncated,
})
}
fn visit_files<F>(root: &Path, max_results: usize, visitor: &mut F) -> Result<(), ContextError>
where
F: FnMut(&Path) -> Result<bool, ContextError>,
{
if max_results == 0 {
return Ok(());
}
let mut stack = vec![root.to_path_buf()];
while let Some(path) = stack.pop() {
let entries = match fs::read_dir(&path) {
Ok(entries) => entries,
Err(_) => continue,
};
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name().to_string_lossy().to_string();
if is_noisy_path(&name) {
continue;
}
let entry_path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry_path);
} else if file_type.is_file() && !visitor(&entry_path)? {
return Ok(());
}
}
}
Ok(())
}
fn normalize_search_line(root: &Path, line: &str) -> String {
let root = root.to_string_lossy();
line.strip_prefix(root.as_ref())
.and_then(|line| line.strip_prefix(std::path::MAIN_SEPARATOR))
.unwrap_or(line)
.replace('\\', "/")
}
fn relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct TruncatedContent {
content: String,
@ -1782,7 +2075,8 @@ mod tests {
"directory_summary".to_string(),
"git_status".to_string(),
"git_diff".to_string(),
"git_commits".to_string()
"git_commits".to_string(),
"repo_search".to_string()
]
);
}
@ -1848,6 +2142,74 @@ mod tests {
assert_eq!(metadata.kind, ContextKind::GitHistory);
}
#[test]
fn repository_search_provider_metadata_is_search_context() {
let metadata = RepositorySearchContextProvider::default().metadata();
assert_eq!(metadata.name, "repo_search");
assert_eq!(metadata.kind, ContextKind::SearchResult);
}
#[test]
fn repository_search_path_mode_collects_matching_paths() {
let tempdir = tempfile::tempdir().expect("tempdir");
let src = tempdir.path().join("src");
fs::create_dir(&src).expect("create src");
fs::write(src.join("ContextProvider.rs"), "needle").expect("write file");
fs::write(tempdir.path().join("README.md"), "readme").expect("write readme");
let provider = RepositorySearchContextProvider::default();
let mut provider_options = HashMap::new();
provider_options.insert("mode".into(), "path".into());
provider_options.insert("query".into(), "contextprovider".into());
let entry = provider
.collect(ContextProviderRequest {
path: Some(tempdir.path().to_path_buf()),
provider_options,
..ContextProviderRequest::default()
})
.expect("search context");
assert_eq!(entry.kind, ContextKind::SearchResult);
assert!(entry.content.contains("mode: path"));
assert!(entry.content.contains("src/ContextProvider.rs"));
assert_eq!(
entry.provenance.provider_details.get("engine"),
Some(&"fallback".to_string())
);
}
#[test]
fn fallback_text_search_reports_locations_and_truncation() {
let tempdir = tempfile::tempdir().expect("tempdir");
fs::write(
tempdir.path().join("alpha.txt"),
"first Needle\nsecond needle\nthird needle\n",
)
.expect("write text");
let search =
fallback_text_search(tempdir.path(), "needle", 2, 256 * 1024).expect("fallback search");
assert_eq!(search.engine, SearchEngine::Fallback);
assert!(search.truncated);
assert_eq!(search.results.len(), 2);
assert!(search.results[0].contains("alpha.txt:1:7:first Needle"));
assert!(search.results[1].contains("alpha.txt:2:8:second needle"));
}
#[test]
fn fallback_text_search_skips_large_files() {
let tempdir = tempfile::tempdir().expect("tempdir");
fs::write(tempdir.path().join("large.txt"), "needle").expect("write large");
let search =
fallback_text_search(tempdir.path(), "needle", 10, 2).expect("fallback search");
assert!(search.results.is_empty());
}
#[test]
fn git_commit_count_is_bounded() {
assert_eq!(parse_git_commit_count(None).expect("default"), 5);

View File

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