From 37dca8954db24acf06700f9549acde05c05c44ee Mon Sep 17 00:00:00 2001 From: "K. Hodges" Date: Fri, 5 Jun 2026 01:54:01 -0700 Subject: [PATCH] Context manager --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 6 +- docs/DESIGN.md | 154 ++++++ docs/PHASES.md | 532 ++++++++++++++----- docs/versioning.md | 1 + src/app.rs | 61 +++ src/context.rs | 1217 ++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + 9 files changed, 1859 insertions(+), 117 deletions(-) create mode 100644 src/context.rs diff --git a/Cargo.lock b/Cargo.lock index 6f56f87..9d209e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 980ce9a..03b8fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.1.0" +version = "0.2.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 59c1529..fb1c2b6 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,11 @@ Phase 1 is closed. The current implementation supports the first shell-adjacent model chat milestone: a Rust CLI, OpenAI-compatible provider abstraction, PowerShell/POSIX shell-family selection, command-suggestion formatting, markdown transcripts, and a basic interactive REPL. -The roadmap is tracked in [docs/PHASES.md](docs/PHASES.md). Phase 2 moves toward explicit context, modes, hotkeys, and stronger operator controls. +The codebase also contains the initial 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/directory-summary providers, deterministic pruning, and prompt-context rendering. REPL context commands are still planned. + +The active milestone is Phase 1.5, the explicit context engine foundation tracked in [docs/tasks/phase15_context_tasks.md](docs/tasks/phase15_context_tasks.md). Phase 2 builds on that context engine with stances, safer command handling, hotkeys, and stronger operator controls. + +The broader roadmap is tracked in [docs/PHASES.md](docs/PHASES.md) and [docs/tasks](docs/tasks). ## Running Locally diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f251aec..27dc480 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -432,6 +432,160 @@ Memory scopes may include: * session * ephemeral +--- +## Context Philosophy + +Context is a first-class system within Exoshell. + +Most AI systems accumulate context implicitly through hidden memory, opaque retrieval systems, session state, telemetry, or behavioral profiling. + +Exoshell rejects this model. + +Context should be explicit. + +The operator should always be able to answer: + +* What information is being sent to the model? +* Where did it come from? +* Why is it present? +* How large is it? +* How can I remove it? + +Context is not hidden machinery. + +Context is instrumentation. + +### Context As Cargo + +Exoshell treats context as cargo carried into a conversation. + +The operator chooses what to bring. + +Examples include: + +* files +* command output +* logs +* notes +* repository summaries +* notebook entries +* search results +* git state + +Context should never silently appear. + +The operator should deliberately attach information when it becomes useful. + +### Inspectability + +All active context should be visible. + +The user should be able to inspect: + +* source +* size +* type +* age +* priority +* inclusion status + +before a request is sent to a model. + +The system should make context visible enough that an operator can reason about it the same way they reason about shell pipelines. + +### Provenance + +Every context entry should retain provenance. + +The operator should be able to determine: + +* where context originated +* when it was added +* how it entered the session + +Examples: + +* manually pasted +* loaded from a file +* generated from git +* generated from search +* imported from a notebook + +Context without provenance is difficult to trust. + +### Explicit Inclusion + +Possessing context and sending context are separate concepts. + +A context entry may exist within a session without being included in model requests. + +Operators should be able to: + +* enable context +* disable context +* pin context +* prioritize context +* remove context + +without destroying underlying information. + +### Context Budgets + +Context is a finite resource. + +Exoshell should expose: + +* approximate token usage +* approximate character usage +* pruning decisions +* compression decisions + +Users should never be surprised by context loss. + +If context must be reduced, the system should explain: + +* what was removed +* why it was removed +* what remains + +### Context Is Not Memory + +Context and memory serve different purposes. + +Context is active operational state. + +Memory is retained knowledge. + +Context is temporary, task-oriented, and immediately relevant. + +Memory is persistent, searchable, and intentionally preserved. + +Exoshell should maintain a clear boundary between the two. + +### Operator Ownership + +The operator owns context. + +Not the model. + +Not the application. + +Not a hidden retrieval system. + +Context should remain: + +* visible +* editable +* removable +* exportable +* inspectable + +at all times. + +A practitioner should never need faith to understand what information Exoshell is using. + +The system should make context obvious enough that trust emerges naturally from visibility. + βΈ» Logging Philosophy diff --git a/docs/PHASES.md b/docs/PHASES.md index 4e18c9c..902d3ca 100644 --- a/docs/PHASES.md +++ b/docs/PHASES.md @@ -1,161 +1,465 @@ -# Exoshell Phases +# Roadmap -This roadmap turns the project vision into concrete build phases. The phases are intentionally ordered from the smallest useful shell-adjacent assistant toward the broader cognitive shell described in `README.md` and `docs/DESIGN.md`. +Detailed milestone task lists live in `docs/tasks/`. -## Phase 1: Shell-Adjacent Model Chat +Current ordering: -Goal: ship a usable terminal program that lets a user talk to a model from PowerShell, bash, zsh, and similar shells without replacing the shell. +* Phase 1 is closed. +* Phase 1.5 establishes the explicit context engine foundation before Phase 2. +* Phase 2 integrates the context engine with stances, safer command handling, and operator controls. +* Phase 3 adds project awareness and operational memory on top of visible, user-controlled context. -Core features: +# Phase 3 Tasks -- CLI entry point, likely `exoshell`, that runs as a normal terminal application. -- Provider configuration for at least one OpenAI-compatible backend. -- Local-first-compatible model adapter interface, even if the first implementation targets one backend. -- Interactive chat loop with streaming responses. -- Basic command suggestion formatting that distinguishes prose, shell commands, and warnings. -- Explicit copy/paste-oriented workflow: Exoshell suggests commands, the user remains responsible for execution. -- Cross-platform terminal behavior on Windows, macOS, and Linux. -- Shell-family awareness for PowerShell versus POSIX-like shells. -- Basic session transcript saved as markdown. -- Minimal config file for provider, model, default shell family, and notebook location. -- Clear error handling for missing API keys, provider failures, and interrupted requests. +Phase 3 goal: provide useful project awareness and operational memory while keeping all context visible, inspectable, and user-controlled. -User experience: -- User should be able to run "exo this is a prompt" and get a response, the program exiting -- User should be able to run exo (without a prompt) and have an open session, or "exo this is a prompt --stay" (or a more appropriate flag) to keep the shell/conversation open -- User should be able to pipe (bash) or powershell equivalent into exo and have it treated as a prompt, with the additional context of the command run that sent the pipe, cwd, etc. - - For example, "ls | exo" should send a prompt similar to "{"command": "ls", "stdout": "foo bar baz", "cwd": "/foo/bar"}" and any relevant system prompts -Non-goals: +## P3-001: Project Root Detection -- PTY control of the user's shell. -- Hotkeys that inject commands into the shell. -- Ambient context collection. -- Tree-sitter repo indexing. -- Long-term memory. -- Stances and personalities beyond a fixed default behavior. -- Autonomous command execution. +Status: planned. -Exit criteria: +Outcome: Exoshell can identify and operate against a project root. -- A user can install/run Exoshell, configure a model backend, ask for help from a terminal, receive command suggestions appropriate to PowerShell or bash/zsh, and save the session transcript. -- The implementation has a small automated test suite for provider abstraction, shell-family prompt behavior, config loading, and transcript writing. +Acceptance criteria: -## Phase 2: Context, Modes, and Operator Controls +* Detect Git repositories. +* Detect project roots using common markers. +* User can override detected root. +* Current project root is visible. +* Tests cover nested repositories and overrides. -Goal: make Exoshell feel like an operational overlay instead of a generic chat client. +## P3-002: Project Context Model -Core features: +Status: planned. -- Explicit context commands for adding files, command output, directory summaries, and pasted logs. -- Session-scoped context panel or context summary visible in the TUI. -- Stances such as `operator`, `audit`, `teach`, and `quiet`. -- Hotkeys for accepting, copying, explaining, and discarding suggested commands. -- Safer command suggestion UI with destructive-command detection and confirmation language. -- Context budget management with visible token or character estimates. -- Shell-specific command rendering and explanation for PowerShell and POSIX-like shells. -- Notebook improvements: title, timestamps, model metadata, commands suggested, and user-marked discoveries. -- Basic configuration profiles for different providers and shells. +Outcome: Exoshell has a structured representation of project information. -Non-goals: +Acceptance criteria: -- Passive screen watching. -- Full PTY embedding. -- Repository-wide semantic indexing. -- Long-term memory outside user-controlled markdown/log files. +* Project metadata includes root path, repository type, language hints, and discovery timestamps. +* Metadata is serializable. +* Metadata can be displayed in the UI. +* Tests cover serialization and loading. -Exit criteria: +## P3-003: Repository Summary Generation -- A user can deliberately attach context, switch stance, inspect what context is being sent, and act on suggestions through predictable terminal controls. +Status: planned. -## Phase 3: Repo Awareness and Operational Notebooks +Outcome: Exoshell can generate a high-level summary of a repository. -Goal: give Exoshell useful project awareness while keeping memory inspectable and scoped. +Acceptance criteria: -Core features: +* Summary includes major directories. +* Summary includes language breakdown. +* Summary identifies likely entry points. +* Summary avoids reading entire repositories by default. +* Tests cover large repositories. -- Repo detection and project-root selection. -- File tree summaries with ignore rules. -- Structural parsing for common languages using tree-sitter where practical. -- Commands to add source files, symbols, recent git changes, and test output to context. -- Markdown notebooks scoped by global, repo, task, and session. -- Searchable operational notes and discoveries. -- Runbook generation from session notes. -- Diff-oriented patch suggestions that require user review. -- Better uncertainty reporting through explicit signal strength. +## P3-004: Repository Ignore Rules -Non-goals: +Status: planned. -- Hidden long-term behavioral profiling. -- Blind patch application. -- Autonomous multi-file rewrites. +Outcome: repository scanning remains efficient. -Exit criteria: +Acceptance criteria: -- A user can open a repo, ask informed questions about local code and recent command output, preserve useful notes, and generate reviewable runbooks or patch suggestions. +* Honors .gitignore when practical. +* Supports Exoshell-specific ignore rules. +* Skips common build artifacts. +* Ignore behavior is configurable. +* Tests cover ignore matching. -## Phase 4: Follow Mode and Shell Integration +## P3-005: File Inventory Builder -Goal: let Exoshell observe explicit shell activity and provide contextual help without becoming a replacement shell. +Status: planned. -Core features: +Outcome: users can inspect repository contents. -- PTY or shell-hook integration strategy for supported environments. -- Follow mode that can ingest selected command history, stdout/stderr snippets, current directory, and exit status. -- User-visible controls for what is observed and retained. -- Hotkey workflows for sending command output to Exoshell. -- Optional paste-to-shell and paste-and-run flows with clear confirmation boundaries. -- Command outcome interpretation and next-step suggestions. -- Per-shell integration docs for PowerShell, bash, zsh, and fish. +Acceptance criteria: -Non-goals: +* Generates file inventories. +* Supports filtering by extension. +* Supports filtering by path. +* Supports size limits. +* Tests cover large inventories. -- Covert monitoring. -- Unreviewed execution. -- Replacing the user's shell configuration. +## P3-006: Symbol Discovery Framework -Exit criteria: +Status: planned. -- A user can work in their normal shell while Exoshell follows explicitly permitted context and offers useful, nonintrusive operational guidance. +Outcome: Exoshell can discover likely symbols without full semantic indexing. -## Phase 5: Tuning, Extensibility, and Local-First Depth +Acceptance criteria: -Goal: make Exoshell configurable, hackable, and effective with weaker local models. +* Extracts functions, structs, classes, interfaces, and modules where practical. +* Stores symbol metadata. +* Supports lookup by name. +* Supports language-specific extractors. +* Tests cover supported languages. -Core features: +## P3-007: Language Detector -- Robust local model support through OpenAI-compatible local servers and/or dedicated adapters. -- Prompt and stance customization. -- User-editable command policies and safety rules. -- Extension points for tools, context providers, and notebook processors. -- Model routing by task type. -- Context compression and retrieval tuned for smaller models. -- Export/import of user configuration and notebooks. -- Advanced visual themes that remain restrained and terminal-native. +Status: planned. -Non-goals: +Outcome: Exoshell understands repository language composition. -- Cloud lock-in. -- Manager analytics. -- Personality behavior that compromises operational clarity. +Acceptance criteria: -Exit criteria: +* Detects primary languages. +* Detects mixed-language repositories. +* Handles generated code separately. +* Displays language summary. +* Tests cover representative repositories. -- A technical user can tune Exoshell to their environment, run local-first workflows, and extend context/tool behavior without modifying core code. +## P3-008: Git Status Context Provider -## Phase 6: Mature Cognitive Shell Overlay +Status: planned. -Goal: converge on the broader vision: a calm, inspectable, practitioner-oriented cognitive shell environment. +Outcome: users can add current Git state as context. -Core features: +Acceptance criteria: -- Mature cockpit-style TUI with dense but readable operational instrumentation. -- Stable shell integrations across major platforms. -- High-quality repo, session, task, and notebook workflows. -- Reviewable command, patch, and runbook pipelines. -- Strong safety posture around destructive commands and hidden state. -- Comprehensive docs for installation, configuration, shell integration, model backends, stances, notebooks, and local-first operation. -- Packaging and release automation. +* Captures branch. +* Captures modified files. +* Captures staged files. +* Captures untracked files. +* Tests cover detached HEAD and clean repositories. -Exit criteria: +## P3-009: Recent Commit Context Provider -- Exoshell is a dependable daily-driver assistant for terminal-native practitioners who want AI augmentation while preserving control and understanding. +Status: planned. + +Outcome: users can add recent history to context. + +Acceptance criteria: + +* Supports configurable commit count. +* Includes author, timestamp, message, and changed files. +* Supports filtering by author. +* Supports filtering by path. +* Tests cover repositories with no commits. + +## P3-010: Diff Context Provider + +Status: planned. + +Outcome: users can attach diffs to prompts. + +Acceptance criteria: + +* Supports staged diffs. +* Supports unstaged diffs. +* Supports specific files. +* Large diffs are truncated visibly. +* Tests cover truncation behavior. + +## P3-011: Search Provider Framework + +Status: planned. + +Outcome: Exoshell can search repositories consistently. + +Acceptance criteria: + +* Search abstraction exists. +* Supports text search. +* Supports path search. +* Supports symbol search. +* Tests cover provider behavior. + +## P3-012: Ripgrep Integration + +Status: planned. + +Outcome: repository search is fast and useful. + +Acceptance criteria: + +* Uses ripgrep when available. +* Provides fallback behavior. +* Search results include file and line information. +* Results are attachable as context. +* Tests cover common searches. + +## P3-013: Source File Context Commands + +Status: planned. + +Outcome: users can add code directly to context. + +Acceptance criteria: + +* Supports adding files. +* Supports adding line ranges. +* Supports adding symbols. +* Supports adding search results. +* Tests cover invalid ranges. + +## P3-014: Context Compression Pipeline + +Status: planned. + +Outcome: large repositories remain usable. + +Acceptance criteria: + +* Large context can be summarized. +* Compression preserves source references. +* Compression is visible to the user. +* Original context remains inspectable. +* Tests cover compression logic. + +## P3-015: Global Notebook Support + +Status: planned. + +Outcome: users can maintain global notes. + +Acceptance criteria: + +* Global notebook exists outside repositories. +* Notes are markdown. +* Notes are searchable. +* Notes are user-editable. +* Tests cover creation and loading. + +## P3-016: Repository Notebook Support + +Status: planned. + +Outcome: each repository can maintain its own notebook. + +Acceptance criteria: + +* Notebook is scoped to repository. +* Notebook persists between sessions. +* Notebook supports markdown. +* Notebook location is configurable. +* Tests cover notebook loading. + +## P3-017: Task Notebook Support + +Status: planned. + +Outcome: users can organize work around tasks. + +Acceptance criteria: + +* Users can create tasks. +* Tasks have notes. +* Tasks can link context entries. +* Tasks can be completed or archived. +* Tests cover task lifecycle. + +## P3-018: Notebook Search + +Status: planned. + +Outcome: stored discoveries remain useful. + +Acceptance criteria: + +* Supports keyword search. +* Supports filtering by notebook type. +* Results include source references. +* Results are attachable to prompts. +* Tests cover notebook search. + +## P3-019: Discovery Linking + +Status: planned. + +Outcome: findings become navigable knowledge. + +Acceptance criteria: + +* Discoveries can link files. +* Discoveries can link symbols. +* Discoveries can link commits. +* Discoveries can link tasks. +* Tests cover link integrity. + +## P3-020: Runbook Generation + +Status: planned. + +Outcome: sessions can produce operational documentation. + +Acceptance criteria: + +* Generates markdown runbooks. +* Includes commands, findings, and notes. +* Includes timestamps. +* Includes source references. +* Tests cover runbook generation. + +## P3-021: Session Summarization + +Status: planned. + +Outcome: long sessions remain manageable. + +Acceptance criteria: + +* Session summaries are generated on demand. +* Summaries preserve important discoveries. +* Summaries include linked context. +* Summaries are written to notebooks. +* Tests cover summary generation. + +## P3-022: Patch Suggestion Model + +Status: planned. + +Outcome: Exoshell can suggest code modifications. + +Acceptance criteria: + +* Suggestions are emitted as diffs. +* Suggestions never modify files automatically. +* Suggestions include rationale. +* Suggestions include uncertainty when appropriate. +* Tests cover patch formatting. + +## P3-023: Diff Renderer + +Status: planned. + +Outcome: code changes are reviewable. + +Acceptance criteria: + +* Unified diff rendering. +* Syntax-aware formatting where practical. +* Clear additions and removals. +* Works without ANSI color. +* Tests cover rendering. + +## P3-024: Patch Export + +Status: planned. + +Outcome: users can save suggested patches. + +Acceptance criteria: + +* Exports standard patch files. +* Exports markdown review files. +* Includes metadata. +* Does not modify repository state. +* Tests cover export behavior. + +## P3-025: Evidence and Confidence Framework + +Status: planned. + +Outcome: Exoshell communicates uncertainty consistently. + +Acceptance criteria: + +* Responses distinguish evidence from inference. +* Confidence labels are documented. +* Confidence metadata can be rendered. +* Confidence survives transcript export. +* Tests cover confidence formatting. + +## P3-026: Code Review Stance + +Status: planned. + +Outcome: Exoshell can behave like a skeptical reviewer. + +Acceptance criteria: + +* Focuses on correctness. +* Focuses on maintainability. +* Focuses on security. +* Prioritizes findings. +* Snapshot tests cover stance behavior. + +## P3-027: Repository Dashboard + +Status: planned. + +Outcome: users can inspect project state quickly. + +Acceptance criteria: + +* Shows repository metadata. +* Shows notebook summary. +* Shows recent discoveries. +* Shows current task. +* Displays cleanly in terminal environments. + +## P3-028: Tree-Sitter Foundation + +Status: planned. + +Outcome: semantic parsing foundation exists for supported languages. + +Acceptance criteria: + +* Tree-sitter integration is optional. +* Supports at least one language initially. +* Symbol extraction can use tree-sitter. +* Fallback path exists when unavailable. +* Tests cover parser loading. + +## P3-029: Tree-Sitter Symbol Provider + +Status: planned. + +Outcome: symbol extraction becomes more accurate. + +Acceptance criteria: + +* Extracts functions. +* Extracts classes/structs. +* Extracts methods. +* Preserves source locations. +* Tests cover supported languages. + +## P3-030: Phase 3 Documentation + +Status: planned. + +Outcome: users understand project-aware workflows. + +Acceptance criteria: + +* Documentation covers repositories. +* Documentation covers notebooks. +* Documentation covers patch suggestions. +* Documentation covers confidence reporting. +* Documentation covers limitations. + +## P3-031: Phase 3 Test Coverage + +Status: planned. + +Outcome: repository-awareness features remain reliable. + +Acceptance criteria: + +* cargo test covers discovery, search, notebooks, patch generation, and confidence handling. +* Snapshot tests protect prompt assembly and patch formatting. +* Manual integration tests remain separate. +* Verification workflow is documented. + +## P3-032: Phase 3 Manual Acceptance Test + +Outcome: repository awareness milestone is validated. + +Acceptance criteria: + +* Open a repository. +* Generate repository summary. +* Add git diff context. +* Add recent commit context. +* Search for a symbol. +* Create a task notebook. +* Record discoveries. +* Generate a runbook. +* Request a patch suggestion. +* Export the patch. +* Verify no files are modified automatically. +* Verify all context remains inspectable. diff --git a/docs/versioning.md b/docs/versioning.md index 986914d..5b53ea5 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -100,3 +100,4 @@ Preserve existing codenames in project history. Historical codenames should be tracked in docs/versioning.md below * 0.1.0 packet-kobold +* 0.2.0 context-relic diff --git a/src/app.rs b/src/app.rs index 749fbc4..700473b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; use crate::config::Config; +use crate::context::{ + ContextProviderRegistry, SessionContextStore, register_default_context_providers, +}; use crate::prompts::phase1_system_prompt; use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; use crate::repl::ReplError; @@ -12,6 +15,8 @@ pub struct App { provider: Box, messages: Vec, transcript: Transcript, + _context_store: SessionContextStore, + _context_registry: ContextProviderRegistry, } impl App { @@ -25,12 +30,17 @@ impl App { ChatRole::System, phase1_system_prompt(config.shell.family), )]; + let mut context_registry = ContextProviderRegistry::new(); + register_default_context_providers(&mut context_registry) + .expect("default context providers should register"); Self { config, provider, messages, transcript, + _context_store: SessionContextStore::new(), + _context_registry: context_registry, } } @@ -151,6 +161,7 @@ impl CliOptions { #[cfg(test)] mod tests { use super::*; + use crate::config::{ProviderConfig, ShellConfig, TranscriptConfig}; #[test] fn parses_config_path() { @@ -184,4 +195,54 @@ mod tests { assert_eq!(options.transcript_directory, Some(PathBuf::from("out"))); assert!(options.no_color); } + + #[test] + fn app_registers_default_context_providers_on_startup() { + let app = App::new(test_config(), Box::new(NoopProvider)); + + let provider_names: Vec = app + ._context_registry + .list() + .into_iter() + .map(|metadata| metadata.name) + .collect(); + + assert_eq!( + provider_names, + vec![ + "manual".to_string(), + "file".to_string(), + "command_output".to_string(), + "directory_summary".to_string() + ] + ); + assert_eq!(app._context_store.total_size().characters, 0); + } + + struct NoopProvider; + + #[async_trait::async_trait] + impl Provider for NoopProvider { + async fn chat(&self, _request: ChatRequest) -> Result { + Ok(ChatResponse::Complete("noop".into())) + } + } + + fn test_config() -> Config { + Config { + provider: ProviderConfig { + base_url: "http://localhost:11434/v1".into(), + api_key: "test-key".into(), + api_key_env: "EXOSHELL_TEST_KEY".into(), + model: "test-model".into(), + }, + shell: ShellConfig { + family: ShellFamily::PowerShell, + }, + transcript: TranscriptConfig { + directory: PathBuf::from("transcripts"), + enabled: false, + }, + } + } } diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..ad0c930 --- /dev/null +++ b/src/context.rs @@ -0,0 +1,1217 @@ +use std::collections::HashMap; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextEntry { + pub id: String, + pub kind: ContextKind, + pub title: String, + pub enabled: bool, + pub pinned: bool, + pub priority: ContextPriority, + pub created_at_epoch_ms: u128, + pub provenance: ContextProvenance, + pub content: String, + pub size: ContextSize, +} + +impl ContextEntry { + pub fn new( + id: impl Into, + kind: ContextKind, + title: impl Into, + provenance: ContextProvenance, + content: impl Into, + ) -> Self { + let content = content.into(); + Self { + id: id.into(), + kind, + title: title.into(), + enabled: true, + pinned: false, + priority: ContextPriority::default(), + created_at_epoch_ms: unix_millis(), + provenance, + size: ContextSize::from_content(&content), + content, + } + } + + pub fn with_priority(mut self, priority: ContextPriority) -> Self { + self.priority = priority; + self + } + + pub fn with_pinned(mut self, pinned: bool) -> Self { + self.pinned = pinned; + self + } + + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextKind { + File, + CommandOutput, + DirectorySummary, + Log, + Note, + SearchResult, + NotebookEntry, + Manual, + Unknown(String), +} + +impl fmt::Display for ContextKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File => formatter.write_str("file"), + Self::CommandOutput => formatter.write_str("command_output"), + Self::DirectorySummary => formatter.write_str("directory_summary"), + Self::Log => formatter.write_str("log"), + Self::Note => formatter.write_str("note"), + Self::SearchResult => formatter.write_str("search_result"), + Self::NotebookEntry => formatter.write_str("notebook_entry"), + Self::Manual => formatter.write_str("manual"), + Self::Unknown(value) => write!(formatter, "unknown:{value}"), + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Default, + serde::Serialize, + serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum ContextPriority { + Low, + #[default] + Normal, + High, + Critical, +} + +impl fmt::Display for ContextPriority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Low => formatter.write_str("low"), + Self::Normal => formatter.write_str("normal"), + Self::High => formatter.write_str("high"), + Self::Critical => formatter.write_str("critical"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextProvenance { + pub origin: ContextOrigin, + pub source_path: Option, + pub command: Option, + pub cwd: Option, + pub provider_details: HashMap, +} + +impl ContextProvenance { + pub fn new(origin: ContextOrigin) -> Self { + Self { + origin, + source_path: None, + command: None, + cwd: None, + provider_details: HashMap::new(), + } + } + + pub fn manual() -> Self { + Self::new(ContextOrigin::Manual) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextOrigin { + Manual, + File, + CommandOutput, + Notebook, + Git, + Search, + Generated, + Stdin, +} + +impl fmt::Display for ContextOrigin { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Manual => formatter.write_str("manual"), + Self::File => formatter.write_str("file"), + Self::CommandOutput => formatter.write_str("command_output"), + Self::Notebook => formatter.write_str("notebook"), + Self::Git => formatter.write_str("git"), + Self::Search => formatter.write_str("search"), + Self::Generated => formatter.write_str("generated"), + Self::Stdin => formatter.write_str("stdin"), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ContextSize { + pub characters: usize, + pub estimated_tokens: usize, +} + +impl ContextSize { + pub fn from_content(content: &str) -> Self { + let characters = content.chars().count(); + Self { + characters, + estimated_tokens: estimate_tokens(characters), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextProviderMetadata { + pub name: String, + pub kind: ContextKind, + pub description: String, +} + +pub trait ContextProvider: Send + Sync { + fn metadata(&self) -> ContextProviderMetadata; + fn collect(&self, request: ContextProviderRequest) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ContextProviderRequest { + pub title: Option, + pub content: Option, + pub stdout: Option, + pub stderr: Option, + pub exit_code: Option, + pub path: Option, + pub command: Option, + pub cwd: Option, + pub provider_options: HashMap, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ContextError { + #[error("context not found: {0}")] + NotFound(String), + #[error("permission denied while reading context: {0}")] + PermissionDenied(String), + #[error("invalid context input: {0}")] + InvalidInput(String), + #[error("unsupported context content: {0}")] + UnsupportedContent(String), + #[error("context is too large: {0}")] + TooLarge(String), + #[error("context provider failed: {0}")] + InternalFailure(String), +} + +pub struct ContextProviderRegistry { + providers: HashMap>, + order: Vec, +} + +impl ContextProviderRegistry { + pub fn new() -> Self { + Self { + providers: HashMap::new(), + order: Vec::new(), + } + } + + pub fn register(&mut self, provider: Box) -> Result<(), ContextError> { + let metadata = provider.metadata(); + if metadata.name.trim().is_empty() { + return Err(ContextError::InvalidInput( + "context provider name is empty".into(), + )); + } + + if self.providers.contains_key(&metadata.name) { + return Err(ContextError::InvalidInput(format!( + "context provider '{}' is already registered", + metadata.name + ))); + } + + self.order.push(metadata.name.clone()); + self.providers.insert(metadata.name, provider); + Ok(()) + } + + pub fn get(&self, name: &str) -> Option<&dyn ContextProvider> { + self.providers.get(name).map(|provider| provider.as_ref()) + } + + pub fn list(&self) -> Vec { + self.order + .iter() + .filter_map(|name| self.providers.get(name)) + .map(|provider| provider.metadata()) + .collect() + } +} + +impl Default for ContextProviderRegistry { + fn default() -> Self { + Self::new() + } +} + +pub fn register_default_context_providers( + registry: &mut ContextProviderRegistry, +) -> Result<(), ContextError> { + registry.register(Box::new(ManualContextProvider))?; + registry.register(Box::new(FileContextProvider::default()))?; + registry.register(Box::new(CommandOutputContextProvider))?; + registry.register(Box::new(DirectorySummaryContextProvider::default()))?; + Ok(()) +} + +pub struct ManualContextProvider; + +impl ContextProvider for ManualContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "manual".into(), + kind: ContextKind::Manual, + description: "adds user-provided text as explicit context".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let content = required_non_empty_content(request.content)?; + Ok(ContextEntry::new( + "", + ContextKind::Manual, + request.title.unwrap_or_else(|| "manual context".into()), + ContextProvenance::manual(), + content, + )) + } +} + +#[derive(Debug, Clone)] +pub struct FileContextProvider { + pub max_bytes: usize, +} + +impl Default for FileContextProvider { + fn default() -> Self { + Self { + max_bytes: 256 * 1024, + } + } +} + +impl ContextProvider for FileContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "file".into(), + kind: ContextKind::File, + description: "loads a UTF-8 text file as explicit context".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let path = request + .path + .ok_or_else(|| ContextError::InvalidInput("file path is required".into()))?; + let metadata = fs::metadata(&path).map_err(|error| file_read_error(&path, error))?; + + if !metadata.is_file() { + return Err(ContextError::InvalidInput(format!( + "{} is not a file", + path.display() + ))); + } + + let byte_len = usize::try_from(metadata.len()).unwrap_or(usize::MAX); + if byte_len > self.max_bytes { + return Err(ContextError::TooLarge(format!( + "{} is {} bytes; limit is {} bytes", + path.display(), + byte_len, + self.max_bytes + ))); + } + + let bytes = fs::read(&path).map_err(|error| file_read_error(&path, error))?; + if bytes.contains(&0) { + return Err(ContextError::UnsupportedContent(format!( + "{} appears to be binary", + path.display() + ))); + } + + let content = String::from_utf8(bytes).map_err(|error| { + ContextError::UnsupportedContent(format!( + "{} is not valid UTF-8: {error}", + path.display() + )) + })?; + + let mut provenance = ContextProvenance::new(ContextOrigin::File); + provenance.source_path = Some(path.clone()); + if let Ok(modified) = metadata.modified() + && let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) + { + provenance.provider_details.insert( + "modified_at_epoch_ms".into(), + duration.as_millis().to_string(), + ); + } + provenance + .provider_details + .insert("byte_size".into(), byte_len.to_string()); + + Ok(ContextEntry::new( + "", + ContextKind::File, + request + .title + .unwrap_or_else(|| file_title(&path).unwrap_or_else(|| path.display().to_string())), + provenance, + content, + )) + } +} + +pub struct CommandOutputContextProvider; + +impl ContextProvider for CommandOutputContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "command_output".into(), + kind: ContextKind::CommandOutput, + description: "adds user-provided command output without executing commands".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let content = command_output_content(&request)?; + let mut provenance = ContextProvenance::new(ContextOrigin::CommandOutput); + provenance.command = request.command; + provenance.cwd = request.cwd; + provenance + .provider_details + .insert("provided_by_user".into(), "true".into()); + if let Some(exit_code) = request.exit_code { + provenance + .provider_details + .insert("exit_code".into(), exit_code.to_string()); + } + + Ok(ContextEntry::new( + "", + ContextKind::CommandOutput, + request.title.unwrap_or_else(|| "command output".into()), + provenance, + content, + )) + } +} + +#[derive(Debug, Clone)] +pub struct DirectorySummaryContextProvider { + pub max_depth: usize, + pub max_entries: usize, +} + +impl Default for DirectorySummaryContextProvider { + fn default() -> Self { + Self { + max_depth: 2, + max_entries: 200, + } + } +} + +impl ContextProvider for DirectorySummaryContextProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: "directory_summary".into(), + kind: ContextKind::DirectorySummary, + description: "summarizes directory names without reading file contents".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let path = request + .path + .ok_or_else(|| ContextError::InvalidInput("directory path is required".into()))?; + let metadata = fs::metadata(&path).map_err(|error| file_read_error(&path, error))?; + if !metadata.is_dir() { + return Err(ContextError::InvalidInput(format!( + "{} is not a directory", + path.display() + ))); + } + + let mut summary = DirectorySummary::default(); + summarize_directory(&path, 0, self.max_depth, self.max_entries, &mut summary)?; + + let mut provenance = ContextProvenance::new(ContextOrigin::Generated); + provenance.source_path = Some(path.clone()); + provenance + .provider_details + .insert("max_depth".into(), self.max_depth.to_string()); + provenance + .provider_details + .insert("max_entries".into(), self.max_entries.to_string()); + provenance + .provider_details + .insert("truncated".into(), summary.truncated.to_string()); + + Ok(ContextEntry::new( + "", + ContextKind::DirectorySummary, + request + .title + .unwrap_or_else(|| format!("directory summary: {}", path.display())), + provenance, + summary.lines.join("\n"), + )) + } +} + +#[derive(Debug, Clone, Default)] +pub struct SessionContextStore { + entries: Vec, + next_id: u64, +} + +impl SessionContextStore { + pub fn new() -> Self { + Self { + entries: Vec::new(), + next_id: 1, + } + } + + pub fn add(&mut self, mut entry: ContextEntry) -> String { + let id = self.next_context_id(); + entry.id = id.clone(); + entry.size = ContextSize::from_content(&entry.content); + self.entries.push(entry); + id + } + + pub fn remove(&mut self, id: &str) -> Option { + let index = self.entries.iter().position(|entry| entry.id == id)?; + Some(self.entries.remove(index)) + } + + pub fn get(&self, id: &str) -> Option<&ContextEntry> { + self.entries.iter().find(|entry| entry.id == id) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut ContextEntry> { + self.entries.iter_mut().find(|entry| entry.id == id) + } + + pub fn entries(&self) -> &[ContextEntry] { + &self.entries + } + + pub fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + pub fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<(), ContextError> { + let entry = self + .get_mut(id) + .ok_or_else(|| ContextError::NotFound(id.to_string()))?; + entry.enabled = enabled; + Ok(()) + } + + pub fn set_pinned(&mut self, id: &str, pinned: bool) -> Result<(), ContextError> { + let entry = self + .get_mut(id) + .ok_or_else(|| ContextError::NotFound(id.to_string()))?; + entry.pinned = pinned; + Ok(()) + } + + pub fn set_priority( + &mut self, + id: &str, + priority: ContextPriority, + ) -> Result<(), ContextError> { + let entry = self + .get_mut(id) + .ok_or_else(|| ContextError::NotFound(id.to_string()))?; + entry.priority = priority; + Ok(()) + } + + pub fn total_size(&self) -> ContextSize { + self.entries.iter().filter(|entry| entry.enabled).fold( + ContextSize { + characters: 0, + estimated_tokens: 0, + }, + |total, entry| ContextSize { + characters: total.characters + entry.size.characters, + estimated_tokens: total.estimated_tokens + entry.size.estimated_tokens, + }, + ) + } + + fn next_context_id(&mut self) -> String { + let id = format!("ctx-{:03}", self.next_id); + self.next_id += 1; + id + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ContextBudget { + pub max_characters: Option, + pub max_estimated_tokens: Option, +} + +impl ContextBudget { + pub fn is_over_budget(&self, size: ContextSize) -> bool { + self.max_characters.is_some_and(|max| size.characters > max) + || self + .max_estimated_tokens + .is_some_and(|max| size.estimated_tokens > max) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContextPruneResult { + pub included_ids: Vec, + pub removed_ids: Vec, + pub final_size: ContextSize, + pub over_budget: bool, +} + +pub fn prune_context(entries: &[ContextEntry], budget: ContextBudget) -> ContextPruneResult { + let enabled: Vec<&ContextEntry> = entries.iter().filter(|entry| entry.enabled).collect(); + let mut included_ids: Vec = enabled.iter().map(|entry| entry.id.clone()).collect(); + let mut final_size = combined_size(&enabled); + + if !budget.is_over_budget(final_size) { + return ContextPruneResult { + included_ids, + removed_ids: Vec::new(), + final_size, + over_budget: false, + }; + } + + let mut candidates = enabled; + candidates.sort_by_key(|entry| { + ( + entry.pinned, + entry.priority, + entries + .iter() + .position(|candidate| candidate.id == entry.id) + .unwrap_or(usize::MAX), + ) + }); + + let mut removed_ids = Vec::new(); + for candidate in candidates { + if !budget.is_over_budget(final_size) { + break; + } + + included_ids.retain(|id| id != &candidate.id); + removed_ids.push(candidate.id.clone()); + final_size.characters = final_size + .characters + .saturating_sub(candidate.size.characters); + final_size.estimated_tokens = final_size + .estimated_tokens + .saturating_sub(candidate.size.estimated_tokens); + } + + ContextPruneResult { + included_ids, + removed_ids, + final_size, + over_budget: budget.is_over_budget(final_size), + } +} + +pub fn render_prompt_context(entries: &[ContextEntry]) -> String { + let mut rendered = String::new(); + for entry in entries.iter().filter(|entry| entry.enabled) { + rendered.push_str(&format!("[Context: {}]\n", entry.id)); + rendered.push_str(&format!("Type: {}\n", entry.kind)); + rendered.push_str(&format!("Title: {}\n", entry.title)); + rendered.push_str(&format!("Origin: {}\n", entry.provenance.origin)); + if let Some(path) = &entry.provenance.source_path { + rendered.push_str(&format!("Path: {}\n", path.display())); + } + if let Some(command) = &entry.provenance.command { + rendered.push_str(&format!("Command: {command}\n")); + } + if let Some(cwd) = &entry.provenance.cwd { + rendered.push_str(&format!("Cwd: {}\n", cwd.display())); + } + rendered.push_str(&format!("Priority: {}\n", entry.priority)); + rendered.push('\n'); + rendered.push_str(&entry.content); + rendered.push_str("\n\n"); + } + + rendered.trim_end().to_string() +} + +fn combined_size(entries: &[&ContextEntry]) -> ContextSize { + entries.iter().fold( + ContextSize { + characters: 0, + estimated_tokens: 0, + }, + |total, entry| ContextSize { + characters: total.characters + entry.size.characters, + estimated_tokens: total.estimated_tokens + entry.size.estimated_tokens, + }, + ) +} + +fn estimate_tokens(characters: usize) -> usize { + characters.div_ceil(4) +} + +fn required_non_empty_content(content: Option) -> Result { + let content = + content.ok_or_else(|| ContextError::InvalidInput("context content is required".into()))?; + if content.trim().is_empty() { + return Err(ContextError::InvalidInput( + "context content cannot be empty".into(), + )); + } + + Ok(content) +} + +fn command_output_content(request: &ContextProviderRequest) -> Result { + if request.stdout.is_none() && request.stderr.is_none() { + return required_non_empty_content(request.content.clone()); + } + + let mut parts = Vec::new(); + if let Some(stdout) = &request.stdout + && !stdout.trim().is_empty() + { + parts.push(format!("stdout:\n{stdout}")); + } + if let Some(stderr) = &request.stderr + && !stderr.trim().is_empty() + { + parts.push(format!("stderr:\n{stderr}")); + } + + if parts.is_empty() { + return Err(ContextError::InvalidInput( + "command output cannot be empty".into(), + )); + } + + Ok(parts.join("\n\n")) +} + +fn file_read_error(path: &Path, error: std::io::Error) -> ContextError { + match error.kind() { + std::io::ErrorKind::NotFound => ContextError::NotFound(path.display().to_string()), + std::io::ErrorKind::PermissionDenied => { + ContextError::PermissionDenied(path.display().to_string()) + } + _ => ContextError::InternalFailure(format!("{}: {error}", path.display())), + } +} + +fn file_title(path: &Path) -> Option { + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| name.to_string()) +} + +#[derive(Default)] +struct DirectorySummary { + lines: Vec, + truncated: bool, +} + +fn summarize_directory( + path: &Path, + depth: usize, + max_depth: usize, + max_entries: usize, + summary: &mut DirectorySummary, +) -> Result<(), ContextError> { + if depth > max_depth || summary.lines.len() >= max_entries { + summary.truncated = true; + return Ok(()); + } + + let mut entries = fs::read_dir(path) + .map_err(|error| file_read_error(path, error))? + .collect::, _>>() + .map_err(|error| file_read_error(path, error))?; + entries.sort_by_key(|entry| entry.file_name()); + + for entry in entries { + if summary.lines.len() >= max_entries { + summary.truncated = true; + break; + } + + let file_name = entry.file_name().to_string_lossy().to_string(); + if is_noisy_path(&file_name) { + continue; + } + + let entry_path = entry.path(); + let kind = entry + .file_type() + .map_err(|error| file_read_error(&entry_path, error))?; + let suffix = if kind.is_dir() { "/" } else { "" }; + let indent = " ".repeat(depth); + summary.lines.push(format!("{indent}{file_name}{suffix}")); + + if kind.is_dir() { + summarize_directory(&entry_path, depth + 1, max_depth, max_entries, summary)?; + } + } + + Ok(()) +} + +fn is_noisy_path(name: &str) -> bool { + matches!( + name, + ".git" + | "target" + | "node_modules" + | ".venv" + | "venv" + | "dist" + | "build" + | ".next" + | ".cache" + ) +} + +fn unix_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before UNIX epoch") + .as_millis() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_entry_serializes_round_trip() { + let mut provenance = ContextProvenance::new(ContextOrigin::File); + provenance.source_path = Some(PathBuf::from("Cargo.toml")); + let entry = ContextEntry::new( + "ctx-001", + ContextKind::File, + "Cargo.toml", + provenance, + "[package]\nname = \"exoshell\"", + ) + .with_priority(ContextPriority::High) + .with_pinned(true); + + let json = serde_json::to_string(&entry).expect("serialize entry"); + let decoded: ContextEntry = serde_json::from_str(&json).expect("deserialize entry"); + + assert_eq!(decoded.id, "ctx-001"); + assert_eq!(decoded.kind, ContextKind::File); + assert_eq!(decoded.priority, ContextPriority::High); + assert!(decoded.pinned); + assert_eq!(decoded.provenance.origin, ContextOrigin::File); + assert_eq!(decoded.size.characters, entry.content.chars().count()); + } + + #[test] + fn priority_defaults_to_normal_and_orders_for_pruning() { + let entry = ContextEntry::new( + "ctx-001", + ContextKind::Manual, + "note", + ContextProvenance::manual(), + "content", + ); + + assert_eq!(entry.priority, ContextPriority::Normal); + assert!(ContextPriority::Low < ContextPriority::Normal); + assert!(ContextPriority::Normal < ContextPriority::High); + assert!(ContextPriority::High < ContextPriority::Critical); + } + + #[test] + fn registry_registers_lists_and_rejects_duplicates() { + let mut registry = ContextProviderRegistry::new(); + registry + .register(Box::new(FakeProvider::new("manual"))) + .expect("register provider"); + + assert!(registry.get("manual").is_some()); + assert_eq!(registry.list()[0].name, "manual"); + + let error = registry + .register(Box::new(FakeProvider::new("manual"))) + .expect_err("duplicate should fail"); + + assert!(matches!(error, ContextError::InvalidInput(_))); + } + + #[test] + fn provider_trait_returns_context_entries() { + let provider = FakeProvider::new("manual"); + let entry = provider + .collect(ContextProviderRequest { + content: Some("operator note".into()), + ..ContextProviderRequest::default() + }) + .expect("collect entry"); + + assert_eq!(entry.kind, ContextKind::Manual); + assert_eq!(entry.content, "operator note"); + } + + #[test] + fn store_generates_stable_human_readable_ids_and_mutates_state() { + let mut store = SessionContextStore::new(); + let first = store.add(sample_entry("placeholder", ContextPriority::Normal, false)); + let second = store.add(sample_entry("placeholder", ContextPriority::Normal, false)); + + assert_eq!(first, "ctx-001"); + assert_eq!(second, "ctx-002"); + assert_eq!(store.entries()[0].id, "ctx-001"); + + store.set_enabled(&first, false).expect("disable"); + store.set_pinned(&first, true).expect("pin"); + store + .set_priority(&first, ContextPriority::Critical) + .expect("priority"); + + let entry = store.get(&first).expect("entry exists"); + assert!(!entry.enabled); + assert!(entry.pinned); + assert_eq!(entry.priority, ContextPriority::Critical); + + assert_eq!(store.remove(&first).expect("removed").id, "ctx-001"); + assert!(store.get(&first).is_none()); + assert_eq!(store.get(&second).expect("second remains").id, "ctx-002"); + } + + #[test] + fn budget_calculation_uses_enabled_entries_only() { + let mut store = SessionContextStore::new(); + let enabled_id = store.add(sample_entry("a", ContextPriority::Normal, false)); + let disabled_id = store.add(sample_entry("12345678", ContextPriority::Normal, false)); + store.set_enabled(&disabled_id, false).expect("disable"); + + let size = store.total_size(); + + assert_eq!(store.get(&enabled_id).expect("enabled").size.characters, 1); + assert_eq!(size.characters, 1); + assert_eq!(size.estimated_tokens, 1); + } + + #[test] + fn pruning_removes_low_priority_unpinned_entries_first() { + let entries = vec![ + sample_entry("low", ContextPriority::Low, false), + sample_entry("critical", ContextPriority::Critical, false), + sample_entry("pinned low", ContextPriority::Low, true), + ]; + let result = prune_context( + &entries, + ContextBudget { + max_characters: Some(18), + max_estimated_tokens: None, + }, + ); + + assert_eq!(result.removed_ids, vec!["ctx-low"]); + assert_eq!( + result.included_ids, + vec!["ctx-critical".to_string(), "ctx-pinned-low".to_string()] + ); + assert!(!result.over_budget); + } + + #[test] + fn prompt_context_renderer_skips_disabled_entries_and_keeps_metadata() { + let mut file = sample_entry("file body", ContextPriority::High, false); + file.kind = ContextKind::File; + file.title = "Cargo.toml".into(); + file.provenance = ContextProvenance::new(ContextOrigin::File); + file.provenance.source_path = Some(PathBuf::from("/repo/Cargo.toml")); + let disabled = sample_entry("disabled", ContextPriority::Normal, false).with_enabled(false); + + let rendered = render_prompt_context(&[file, disabled]); + + assert!(rendered.contains("[Context: ctx-file-body]")); + assert!(rendered.contains("Type: file")); + assert!(rendered.contains("Title: Cargo.toml")); + assert!(rendered.contains("Origin: file")); + assert!(rendered.contains("Path: /repo/Cargo.toml")); + assert!(rendered.contains("file body")); + assert!(!rendered.contains("disabled")); + } + + #[test] + fn context_errors_render_user_facing_messages() { + assert_eq!( + ContextError::NotFound("ctx-999".into()).to_string(), + "context not found: ctx-999" + ); + assert_eq!( + ContextError::TooLarge("file exceeds limit".into()).to_string(), + "context is too large: file exceeds limit" + ); + } + + #[test] + fn default_providers_register_in_order() { + let mut registry = ContextProviderRegistry::new(); + register_default_context_providers(&mut registry).expect("register defaults"); + + let names: Vec = registry + .list() + .into_iter() + .map(|metadata| metadata.name) + .collect(); + + assert_eq!( + names, + vec![ + "manual".to_string(), + "file".to_string(), + "command_output".to_string(), + "directory_summary".to_string() + ] + ); + } + + #[test] + fn manual_provider_rejects_empty_context() { + let error = ManualContextProvider + .collect(ContextProviderRequest { + content: Some(" ".into()), + ..ContextProviderRequest::default() + }) + .expect_err("empty manual context should fail"); + + assert!(matches!(error, ContextError::InvalidInput(_))); + } + + #[test] + fn file_provider_loads_utf8_text_with_provenance() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("note.txt"); + std::fs::write(&path, "hello file").expect("write file"); + + let entry = FileContextProvider { max_bytes: 1024 } + .collect(ContextProviderRequest { + path: Some(path.clone()), + ..ContextProviderRequest::default() + }) + .expect("file context"); + + assert_eq!(entry.kind, ContextKind::File); + assert_eq!(entry.title, "note.txt"); + assert_eq!(entry.content, "hello file"); + assert_eq!(entry.provenance.origin, ContextOrigin::File); + assert_eq!(entry.provenance.source_path, Some(path)); + assert_eq!( + entry.provenance.provider_details.get("byte_size"), + Some(&"10".to_string()) + ); + } + + #[test] + fn file_provider_rejects_missing_binary_and_oversized_files() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let missing = tempdir.path().join("missing.txt"); + let missing_error = FileContextProvider { max_bytes: 1024 } + .collect(ContextProviderRequest { + path: Some(missing), + ..ContextProviderRequest::default() + }) + .expect_err("missing file should fail"); + assert!(matches!(missing_error, ContextError::NotFound(_))); + + let binary = tempdir.path().join("binary.bin"); + std::fs::write(&binary, [1, 0, 2]).expect("write binary"); + let binary_error = FileContextProvider { max_bytes: 1024 } + .collect(ContextProviderRequest { + path: Some(binary), + ..ContextProviderRequest::default() + }) + .expect_err("binary file should fail"); + assert!(matches!(binary_error, ContextError::UnsupportedContent(_))); + + let large = tempdir.path().join("large.txt"); + std::fs::write(&large, "abcdef").expect("write large"); + let large_error = FileContextProvider { max_bytes: 3 } + .collect(ContextProviderRequest { + path: Some(large), + ..ContextProviderRequest::default() + }) + .expect_err("large file should fail"); + assert!(matches!(large_error, ContextError::TooLarge(_))); + } + + #[test] + fn command_output_provider_labels_user_provided_output() { + let entry = CommandOutputContextProvider + .collect(ContextProviderRequest { + title: Some("cargo test output".into()), + stdout: Some("test result: ok".into()), + stderr: Some("warning: slow test".into()), + command: Some("cargo test".into()), + cwd: Some(PathBuf::from("/repo")), + exit_code: Some(0), + ..ContextProviderRequest::default() + }) + .expect("command output context"); + + assert_eq!(entry.kind, ContextKind::CommandOutput); + assert!(entry.content.contains("stdout:\ntest result: ok")); + assert!(entry.content.contains("stderr:\nwarning: slow test")); + assert_eq!(entry.provenance.origin, ContextOrigin::CommandOutput); + assert_eq!(entry.provenance.command, Some("cargo test".into())); + assert_eq!(entry.provenance.cwd, Some(PathBuf::from("/repo"))); + assert_eq!( + entry.provenance.provider_details.get("provided_by_user"), + Some(&"true".to_string()) + ); + assert_eq!( + entry.provenance.provider_details.get("exit_code"), + Some(&"0".to_string()) + ); + } + + #[test] + fn command_output_provider_accepts_stdout_only_and_stderr_only() { + let stdout = CommandOutputContextProvider + .collect(ContextProviderRequest { + stdout: Some("ok".into()), + ..ContextProviderRequest::default() + }) + .expect("stdout only"); + assert_eq!(stdout.content, "stdout:\nok"); + + let stderr = CommandOutputContextProvider + .collect(ContextProviderRequest { + stderr: Some("failed".into()), + ..ContextProviderRequest::default() + }) + .expect("stderr only"); + assert_eq!(stderr.content, "stderr:\nfailed"); + } + + #[test] + fn directory_summary_provider_skips_noisy_paths_and_truncates() { + let tempdir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(tempdir.path().join(".git")).expect("git dir"); + std::fs::create_dir(tempdir.path().join("src")).expect("src dir"); + std::fs::write(tempdir.path().join("src").join("main.rs"), "fn main() {}") + .expect("main file"); + std::fs::write(tempdir.path().join("README.md"), "# readme").expect("readme"); + + let entry = DirectorySummaryContextProvider { + max_depth: 2, + max_entries: 2, + } + .collect(ContextProviderRequest { + path: Some(tempdir.path().to_path_buf()), + ..ContextProviderRequest::default() + }) + .expect("directory summary"); + + assert_eq!(entry.kind, ContextKind::DirectorySummary); + assert!(!entry.content.contains(".git")); + assert!(entry.content.contains("README.md") || entry.content.contains("src/")); + assert_eq!( + entry.provenance.provider_details.get("truncated"), + Some(&"true".to_string()) + ); + } + + struct FakeProvider { + name: String, + } + + impl FakeProvider { + fn new(name: &str) -> Self { + Self { name: name.into() } + } + } + + impl ContextProvider for FakeProvider { + fn metadata(&self) -> ContextProviderMetadata { + ContextProviderMetadata { + name: self.name.clone(), + kind: ContextKind::Manual, + description: "fake manual provider".into(), + } + } + + fn collect(&self, request: ContextProviderRequest) -> Result { + let content = request + .content + .ok_or_else(|| ContextError::InvalidInput("content is required".into()))?; + + Ok(ContextEntry::new( + "", + ContextKind::Manual, + request.title.unwrap_or_else(|| "manual".into()), + ContextProvenance::manual(), + content, + )) + } + } + + fn sample_entry(content: &str, priority: ContextPriority, pinned: bool) -> ContextEntry { + ContextEntry::new( + format!("ctx-{}", content.replace(' ', "-")), + ContextKind::Manual, + content, + ContextProvenance::manual(), + content, + ) + .with_priority(priority) + .with_pinned(pinned) + } +} diff --git a/src/main.rs b/src/main.rs index bb17e2b..9100d5d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +pub mod context; mod formatting; mod prompts; mod providers;