Stances and phase 2 start

This commit is contained in:
K. Hodges 2026-06-06 13:24:29 -07:00
parent c084e2f3c0
commit c552a93a9a
14 changed files with 1144 additions and 49 deletions

2
Cargo.lock generated
View File

@ -129,7 +129,7 @@ dependencies = [
[[package]] [[package]]
name = "exoshell" name = "exoshell"
version = "0.2.0" version = "0.3.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.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"

View File

@ -22,13 +22,13 @@ Sacred rule: enhance skill; do not replace it.
## Project State ## Project State
Phase 1 is closed. Phase 1 and Phase 1.5 are 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 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 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 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 active milestone is Phase 2. 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, and Phase 2 help text. The interaction model is documented in [docs/phase2_interaction_model.md](docs/phase2_interaction_model.md).
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).
@ -55,6 +55,12 @@ cargo run -- --shell powershell
cargo run -- --shell posix cargo run -- --shell posix
``` ```
Select an operating stance:
```sh
cargo run -- --stance audit
```
Exoshell suggests commands. It does not execute them. Exoshell suggests commands. It does not execute them.
## Quality Checks ## Quality Checks
@ -75,6 +81,7 @@ powershell -ExecutionPolicy Bypass -File scripts\manual_phase1_startup.ps1
- [Design](docs/DESIGN.md): project philosophy, interaction model, non-goals, and technical direction. - [Design](docs/DESIGN.md): project philosophy, interaction model, non-goals, and technical direction.
- [Context Engine](docs/context_engine.md): explicit context model, providers, commands, budgets, transcripts, serialization, and redaction limits. - [Context Engine](docs/context_engine.md): explicit context model, providers, commands, budgets, transcripts, serialization, and redaction limits.
- [Phase 2 Interaction Model](docs/phase2_interaction_model.md): prompts, stances, command suggestions, operator actions, and current limitations.
- [Phases](docs/PHASES.md): staged roadmap from Phase 1 through the mature cognitive shell overlay. - [Phases](docs/PHASES.md): staged roadmap from Phase 1 through the mature cognitive shell overlay.
- [Phase 1 Run Guide](docs/phase1_run.md): local setup, config examples, CLI flags, and current limitations. - [Phase 1 Run Guide](docs/phase1_run.md): local setup, config examples, CLI flags, and current limitations.
- [Versioning](docs/versioning.md): semantic versioning and release naming rules. - [Versioning](docs/versioning.md): semantic versioning and release naming rules.

View File

@ -0,0 +1,15 @@
# Known Bugs and Limitations
## Phase 2 command controls
Clipboard integration is not implemented. `/copy <cmd-id>` prints the command instead of writing to the system clipboard.
Command suggestion parsing is based on shell fenced code blocks and does not understand arbitrary prose or malformed markdown.
Destructive command detection is heuristic. It warns on obvious high-risk patterns but cannot prove that an unflagged command is safe.
Advanced TUI keybindings, config profiles, one-shot prompt mode, and user-marked discoveries remain planned Phase 2 work.
## Local verification environment
The current local WSL toolchain reports Cargo 1.75.0 without `rustfmt` or `rustup`. This cannot run the repository's Rust 2024 project locally without installing a newer toolchain.

View File

@ -0,0 +1,130 @@
# Phase 2 Interaction Model
Phase 2 makes Exoshell behave more like an operational overlay than a generic terminal chat client.
The shell remains primary. Exoshell suggests commands, explains tradeoffs, preserves explicit context, and records transcript events. It does not execute commands.
## Core Objects
User input is the operator's prompt in the REPL or a multi-line prompt collected with `/multi`.
Attached context is explicit, session-scoped material selected by the operator. Context comes from the Phase 1.5 context engine: notes, files, pasted command output, piped stdin, and directory summaries. Disabled context remains visible in the session but is not sent to the model.
Stance is a compact behavior fragment added to the system prompt. Supported stances are `operator`, `audit`, `teach`, and `quiet`.
Model response is provider output recorded as assistant transcript text. Exoshell may parse fenced shell blocks from the response as command suggestions.
Command suggestion is a shell fenced code block such as `powershell`, `pwsh`, `sh`, `bash`, `zsh`, or `posix`. Suggestions receive per-response IDs such as `cmd-001`. Exoshell can print, explain, or discard a suggestion by ID. These actions do not execute the command.
Transcript entry is a markdown record of user prompts, assistant responses, context events, budget warnings, stance changes, command suggestions, and command actions.
## Prompt Assembly
Prompt assembly is deterministic:
1. Phase 2 system prompt with the selected shell family and stance.
2. Conversation history before the current user prompt.
3. Attached context rendered by the Phase 1.5 prompt context renderer.
4. Current user prompt.
Context rendering preserves context IDs, types, titles, provenance, priority, and truncation metadata when present.
The context budget check uses the Phase 1.5 context budget settings. `/context stats` and `/panel` also show an approximate full prompt estimate covering the system prompt, conversation history, and attached context.
## Stances
`operator` favors concise next steps, commands, and operational diagnosis.
`audit` prioritizes security, correctness, reliability, and failure modes. It asks the model to separate evidence from inference.
`teach` explains commands and concepts more fully while staying usable in a terminal.
`quiet` minimizes prose and focuses on direct output. It does not suppress safety warnings.
Configure the default stance:
```toml
[interaction]
stance = "audit"
```
Override it from the CLI:
```sh
cargo run -- --stance teach
```
Switch it during a session:
```text
/stance audit
```
## Command Suggestions
The model is instructed to put suggested commands in shell fenced code blocks. Exoshell parses those blocks and displays action IDs.
Example response fragment:
~~~text
Inspect tracked changes first:
```sh
git status --short
```
~~~
Available actions:
```text
/copy cmd-001
/explain cmd-001
/discard cmd-001
```
`/copy` prints the command when clipboard support is unavailable. It does not run the command.
`/explain` describes the shell family, original command text, model note when available, and detected risk warning.
`/discard` marks the suggestion as discarded in session state and records the action in the transcript.
## Risk Detection
Phase 2 includes a simple non-blocking detector for obvious risky commands. It flags patterns such as recursive forced deletion, disk formatting, recursive permission changes, downloaded content piped to an interpreter, credential exposure, and package removal.
False positives are acceptable. A warning means "review this carefully," not "this command is definitely harmful." Lack of a warning does not mean a command is safe.
## Session Controls
Useful commands:
```text
/panel
/context
/context stats
/help
/help context
/help stance
/help commands
```
`/panel` renders stance, shell family, provider/model, transcript state, context entries, and prompt estimates without requiring a TUI.
## Non-Goals
Phase 2 does not make Exoshell an autonomous executor.
Phase 2 does not add hidden memory. Context remains explicit and session-scoped.
Phase 2 does not perform full repository indexing. Project awareness belongs to Phase 3.
Phase 2 does not claim command suggestions are safe. It provides review language and simple detection only.
## Known Limitations
Clipboard integration is not implemented. `/copy` prints the command.
Command parsing is intentionally simple and based on fenced blocks.
Risk detection is heuristic and incomplete.
Advanced TUI keybindings and config profiles remain planned work.

View File

@ -101,3 +101,4 @@ Historical codenames should be tracked in docs/versioning.md below
* 0.1.0 packet-kobold * 0.1.0 packet-kobold
* 0.2.0 context-relic * 0.2.0 context-relic
* 0.3.0 stance-lantern

View File

@ -1,13 +1,14 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use crate::commands::{CommandSuggestion, parse_command_suggestions};
use crate::config::Config; use crate::config::Config;
use crate::context::{ use crate::context::{
ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest, ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest,
SessionContextStore, budget_warning, prune_context, register_default_context_providers, SessionContextStore, budget_warning, prune_context, register_default_context_providers,
render_context_details, render_context_list, render_context_stats, render_prompt_context, render_context_details, render_context_list, render_context_stats,
}; };
use crate::prompts::phase1_system_prompt; use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError}; use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
use crate::repl::ReplError; use crate::repl::ReplError;
use crate::shell::ShellFamily; use crate::shell::ShellFamily;
@ -16,10 +17,11 @@ use crate::transcripts::{Transcript, TranscriptError};
pub struct App { pub struct App {
config: Config, config: Config,
provider: Box<dyn Provider>, provider: Box<dyn Provider>,
messages: Vec<ChatMessage>, conversation: Vec<ChatMessage>,
transcript: Transcript, transcript: Transcript,
context_store: SessionContextStore, context_store: SessionContextStore,
context_registry: ContextProviderRegistry, context_registry: ContextProviderRegistry,
last_command_suggestions: Vec<CommandSuggestion>,
} }
impl App { impl App {
@ -28,11 +30,8 @@ impl App {
"openai-compatible".into(), "openai-compatible".into(),
config.provider.model.clone(), config.provider.model.clone(),
config.shell.family, config.shell.family,
config.interaction.stance,
); );
let messages = vec![ChatMessage::new(
ChatRole::System,
phase1_system_prompt(config.shell.family),
)];
let mut context_registry = ContextProviderRegistry::new(); let mut context_registry = ContextProviderRegistry::new();
register_default_context_providers(&mut context_registry) register_default_context_providers(&mut context_registry)
.expect("default context providers should register"); .expect("default context providers should register");
@ -40,15 +39,16 @@ impl App {
Self { Self {
config, config,
provider, provider,
messages, conversation: Vec::new(),
transcript, transcript,
context_store: SessionContextStore::new(), context_store: SessionContextStore::new(),
context_registry, context_registry,
last_command_suggestions: Vec::new(),
} }
} }
pub async fn send(&mut self, input: String) -> Result<String, AppError> { pub async fn send(&mut self, input: String) -> Result<String, AppError> {
self.messages self.conversation
.push(ChatMessage::new(ChatRole::User, input.clone())); .push(ChatMessage::new(ChatRole::User, input.clone()));
self.transcript.record_user(&input); self.transcript.record_user(&input);
@ -75,9 +75,13 @@ impl App {
return Err(error.into()); return Err(error.into());
} }
}; };
self.messages self.conversation
.push(ChatMessage::new(ChatRole::Assistant, response.clone())); .push(ChatMessage::new(ChatRole::Assistant, response.clone()));
self.transcript.record_assistant(&response); self.transcript.record_assistant(&response);
self.last_command_suggestions = parse_command_suggestions(&response);
for suggestion in &self.last_command_suggestions {
self.transcript.record_command_suggestion(suggestion);
}
Ok(response) Ok(response)
} }
@ -88,11 +92,45 @@ impl App {
return Ok(render_context_list(self.context_store.entries())); return Ok(render_context_list(self.context_store.entries()));
} }
if trimmed == "/panel" {
return Ok(self.render_panel());
}
if trimmed == "/help" {
return Ok(help_overview().into());
}
if let Some(topic) = trimmed.strip_prefix("/help ") {
return Ok(help_topic(topic.trim()).into());
}
if trimmed == "/context stats" { if trimmed == "/context stats" {
let prompt_estimate = self.prompt_budget_estimate();
return Ok(render_context_stats( return Ok(render_context_stats(
self.context_store.stats(), self.context_store.stats(),
self.config.context.budget(), self.config.context.budget(),
)); ) + "\n\nPrompt estimate:\n"
+ &render_prompt_estimate(prompt_estimate));
}
if trimmed == "/stance" {
return Ok(self.render_stance_status());
}
if let Some(stance) = trimmed.strip_prefix("/stance ") {
return self.set_stance(stance.trim());
}
if let Some(id) = trimmed.strip_prefix("/copy ") {
return self.copy_command(id.trim());
}
if let Some(id) = trimmed.strip_prefix("/explain ") {
return self.explain_command(id.trim());
}
if let Some(id) = trimmed.strip_prefix("/discard ") {
return self.discard_command(id.trim());
} }
if let Some(id) = trimmed.strip_prefix("/context show ") { if let Some(id) = trimmed.strip_prefix("/context show ") {
@ -249,24 +287,116 @@ impl App {
return Err(ContextError::TooLarge(warning).into()); return Err(ContextError::TooLarge(warning).into());
} }
let context = render_prompt_context(self.context_store.entries()); Ok(assemble_prompt(
if context.is_empty() { self.config.shell.family,
return Ok(self.messages.clone()); self.config.interaction.stance,
} &self.conversation,
self.context_store.entries(),
budget,
)
.messages)
}
let mut messages = self.messages.clone(); fn prompt_budget_estimate(&self) -> crate::prompts::PromptBudgetEstimate {
let insert_at = messages.len().saturating_sub(1); assemble_prompt(
messages.insert( self.config.shell.family,
insert_at, self.config.interaction.stance,
ChatMessage::new( &self.conversation,
ChatRole::User, self.context_store.entries(),
format!( self.config.context.budget(),
"Explicit session context selected by the operator follows.\n\n{}", )
context .estimate
), }
),
pub fn stance(&self) -> Stance {
self.config.interaction.stance
}
fn render_stance_status(&self) -> String {
format!(
"current stance: {}\navailable stances: {}",
self.config.interaction.stance,
Stance::names()
)
}
fn render_panel(&self) -> String {
format!(
"Exoshell session\nstance: {}\nshell: {}\nprovider: openai-compatible\nmodel: {}\ntranscript: {}\n\nContext\n{}\n\nPrompt estimate\n{}",
self.config.interaction.stance,
self.config.shell.family,
self.config.provider.model,
if self.config.transcript.enabled {
"enabled"
} else {
"disabled"
},
render_context_list(self.context_store.entries()),
render_prompt_estimate(self.prompt_budget_estimate())
)
}
fn set_stance(&mut self, input: &str) -> Result<String, AppError> {
let stance = input
.parse::<Stance>()
.map_err(|error| crate::config::ConfigError::Invalid(error.to_string()))?;
let previous = self.config.interaction.stance;
self.config.interaction.stance = stance;
self.transcript.record_stance_change(previous, stance);
Ok(format!("stance: {stance}"))
}
fn copy_command(&mut self, id: &str) -> Result<String, AppError> {
let command = self.command_suggestion(id)?.command.clone();
self.transcript.record_command_action(
id,
"copy",
"printed command; clipboard support unavailable",
); );
Ok(messages) Ok(format!("clipboard unavailable; command {id}:\n{}", command))
}
fn explain_command(&mut self, id: &str) -> Result<String, AppError> {
let suggestion = self.command_suggestion(id)?.clone();
let mut explanation = format!(
"{} is a {} command suggestion.\n\nCommand:\n{}\n\n",
suggestion.id, suggestion.shell, suggestion.command
);
if let Some(summary) = &suggestion.explanation {
explanation.push_str(&format!("Model note: {summary}\n"));
}
if let Some(warning) = suggestion.detected_risk.warning() {
explanation.push_str(&format!("{warning}\n"));
} else {
explanation.push_str("No obvious destructive pattern was detected. Review before running.\n");
}
self.transcript
.record_command_action(id, "explain", "operator requested explanation");
Ok(explanation.trim_end().to_string())
}
fn discard_command(&mut self, id: &str) -> Result<String, AppError> {
{
let suggestion = self.command_suggestion_mut(id)?;
suggestion.discarded = true;
}
self.transcript
.record_command_action(id, "discard", "operator discarded suggestion");
Ok(format!("{id} discarded"))
}
fn command_suggestion(&self, id: &str) -> Result<&CommandSuggestion, AppError> {
self.last_command_suggestions
.iter()
.find(|suggestion| suggestion.id == id)
.ok_or_else(|| ContextError::NotFound(format!("command suggestion '{id}'")).into())
}
fn command_suggestion_mut(&mut self, id: &str) -> Result<&mut CommandSuggestion, AppError> {
self.last_command_suggestions
.iter_mut()
.find(|suggestion| suggestion.id == id)
.ok_or_else(|| ContextError::NotFound(format!("command suggestion '{id}'")).into())
} }
fn add_context( fn add_context(
@ -365,10 +495,49 @@ fn parse_context_priority_args(input: &str) -> Result<(&str, ContextPriority), C
Ok((id, priority)) Ok((id, priority))
} }
fn help_overview() -> &'static str {
"Commands:
/context list attached context
/context stats show context and prompt budget estimates
/context show <id> inspect a context entry
/context enable|disable <id> control model inclusion
/context pin|unpin <id> control pruning preference
/context priority <id> <value> set low, normal, high, or critical priority
/add-note <text> attach manual context
/add-file <path> attach a UTF-8 file
/add-dir <path> attach a shallow directory summary
/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
/explain <cmd-id> explain a suggested command
/discard <cmd-id> mark a suggested command as discarded
/panel show session, stance, provider, and context state
/multi enter multi-line input
/exit quit and write transcript if enabled
Exoshell suggests commands. It does not execute them."
}
fn help_topic(topic: &str) -> &'static str {
match topic {
"context" => {
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
}
"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."
}
"commands" => {
"Suggested commands appear as fenced shell blocks and get IDs such as cmd-001. Use /copy, /explain, or /discard by ID. Copy prints the command when clipboard support is unavailable and never runs it."
}
_ => "Unknown help topic. Try /help context, /help stance, or /help commands.",
}
}
#[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>,
pub shell_family: Option<ShellFamily>, pub shell_family: Option<ShellFamily>,
pub stance: Option<Stance>,
pub transcript_enabled: Option<bool>, pub transcript_enabled: Option<bool>,
pub transcript_directory: Option<PathBuf>, pub transcript_directory: Option<PathBuf>,
pub context_notes: Vec<String>, pub context_notes: Vec<String>,
@ -404,6 +573,15 @@ impl CliOptions {
}, },
)?); )?);
} }
"--stance" => {
let value = args.next().ok_or_else(|| {
crate::config::ConfigError::Invalid("--stance requires a value".into())
})?;
options.stance =
Some(value.parse().map_err(|error: crate::prompts::StanceError| {
crate::config::ConfigError::Invalid(error.to_string())
})?);
}
"--no-transcript" => options.transcript_enabled = Some(false), "--no-transcript" => options.transcript_enabled = Some(false),
"--transcript-dir" => { "--transcript-dir" => {
let value = args.next().ok_or_else(|| { let value = args.next().ok_or_else(|| {
@ -440,14 +618,14 @@ impl CliOptions {
} }
pub fn help() -> &'static str { pub fn help() -> &'static str {
"Usage: exoshell [--config <path>] [--shell powershell|posix] [--context-note <text>] [--context-file <path>] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them." "Usage: exoshell [--config <path>] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--context-note <text>] [--context-file <path>] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them."
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{ProviderConfig, ShellConfig, TranscriptConfig}; use crate::config::{InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
#[test] #[test]
@ -470,6 +648,8 @@ mod tests {
let options = CliOptions::parse([ let options = CliOptions::parse([
"--shell".to_string(), "--shell".to_string(),
"posix".to_string(), "posix".to_string(),
"--stance".to_string(),
"audit".to_string(),
"--no-transcript".to_string(), "--no-transcript".to_string(),
"--transcript-dir".to_string(), "--transcript-dir".to_string(),
"out".to_string(), "out".to_string(),
@ -482,6 +662,7 @@ mod tests {
.expect("options parse"); .expect("options parse");
assert_eq!(options.shell_family, Some(ShellFamily::Posix)); assert_eq!(options.shell_family, Some(ShellFamily::Posix));
assert_eq!(options.stance, Some(Stance::Audit));
assert_eq!(options.transcript_enabled, Some(true)); assert_eq!(options.transcript_enabled, Some(true));
assert_eq!(options.transcript_directory, Some(PathBuf::from("out"))); assert_eq!(options.transcript_directory, Some(PathBuf::from("out")));
assert_eq!(options.context_notes, vec!["note".to_string()]); assert_eq!(options.context_notes, vec!["note".to_string()]);
@ -551,6 +732,11 @@ mod tests {
.expect("stats") .expect("stats")
.contains("total_entries: 1") .contains("total_entries: 1")
); );
assert!(
app.handle_command("/context stats")
.expect("stats")
.contains("Prompt estimate:")
);
assert_eq!( assert_eq!(
app.handle_command("/context remove ctx-001") app.handle_command("/context remove ctx-001")
.expect("remove"), .expect("remove"),
@ -558,6 +744,24 @@ mod tests {
); );
} }
#[test]
fn stance_command_shows_and_changes_current_stance() {
let mut app = App::new(test_config(), Box::new(NoopProvider));
assert_eq!(app.stance(), Stance::Operator);
assert!(
app.handle_command("/stance")
.expect("stance")
.contains("operator, audit, teach, quiet")
);
assert_eq!(
app.handle_command("/stance audit").expect("set stance"),
"stance: audit"
);
assert_eq!(app.stance(), Stance::Audit);
assert!(app.handle_command("/stance unknown").is_err());
}
#[tokio::test] #[tokio::test]
async fn enabled_context_is_inserted_before_current_user_prompt() { async fn enabled_context_is_inserted_before_current_user_prompt() {
let seen = Arc::new(Mutex::new(Vec::new())); let seen = Arc::new(Mutex::new(Vec::new()));
@ -603,6 +807,52 @@ mod tests {
); );
} }
#[tokio::test]
async fn command_suggestion_actions_use_last_response_ids() {
let mut app = App::new(
test_config(),
Box::new(StaticProvider {
response: "Inspect:\n```powershell\nGet-ChildItem\n```".into(),
}),
);
app.send("suggest a command".into()).await.expect("send");
assert!(
app.handle_command("/copy cmd-001")
.expect("copy")
.contains("Get-ChildItem")
);
assert!(
app.handle_command("/explain cmd-001")
.expect("explain")
.contains("powershell")
);
assert_eq!(
app.handle_command("/discard cmd-001").expect("discard"),
"cmd-001 discarded"
);
assert!(app.handle_command("/copy cmd-999").is_err());
}
#[test]
fn panel_and_help_render_phase2_controls() {
let mut app = App::new(test_config(), Box::new(NoopProvider));
app.handle_command("/add-note repo uses cargo")
.expect("add note");
assert!(
app.handle_command("/panel")
.expect("panel")
.contains("stance: operator")
);
assert!(
app.handle_command("/help commands")
.expect("help")
.contains("/copy")
);
}
#[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()));
@ -681,6 +931,17 @@ mod tests {
} }
} }
struct StaticProvider {
response: String,
}
#[async_trait::async_trait]
impl Provider for StaticProvider {
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
Ok(ChatResponse::Complete(self.response.clone()))
}
}
fn test_config() -> Config { fn test_config() -> Config {
Config { Config {
provider: ProviderConfig { provider: ProviderConfig {
@ -693,6 +954,9 @@ mod tests {
shell: ShellConfig { shell: ShellConfig {
family: ShellFamily::PowerShell, family: ShellFamily::PowerShell,
}, },
interaction: InteractionConfig {
stance: Stance::Operator,
},
transcript: TranscriptConfig { transcript: TranscriptConfig {
directory: PathBuf::from("transcripts"), directory: PathBuf::from("transcripts"),
enabled: false, enabled: false,

283
src/commands.rs Normal file
View File

@ -0,0 +1,283 @@
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandSuggestion {
pub id: String,
pub shell: CommandShell,
pub command: String,
pub explanation: Option<String>,
pub model_risk: Option<RiskLevel>,
pub detected_risk: CommandRisk,
pub discarded: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandShell {
PowerShell,
Posix,
Unknown,
}
impl fmt::Display for CommandShell {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::PowerShell => formatter.write_str("powershell"),
Self::Posix => formatter.write_str("posix"),
Self::Unknown => formatter.write_str("unknown"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RiskLevel {
Low,
Medium,
High,
}
impl fmt::Display for RiskLevel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => formatter.write_str("low"),
Self::Medium => formatter.write_str("medium"),
Self::High => formatter.write_str("high"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandRisk {
pub level: RiskLevel,
pub reasons: Vec<String>,
}
impl CommandRisk {
pub fn none() -> Self {
Self {
level: RiskLevel::Low,
reasons: Vec::new(),
}
}
pub fn warning(&self) -> Option<String> {
if self.reasons.is_empty() {
None
} else {
Some(format!(
"review required: {}",
self.reasons.join("; ")
))
}
}
}
pub fn parse_command_suggestions(response: &str) -> Vec<CommandSuggestion> {
let mut suggestions = Vec::new();
let mut lines = response.lines().peekable();
let mut previous_text = Vec::new();
while let Some(line) = lines.next() {
let Some(shell) = shell_from_fence(line) else {
if !line.trim().is_empty() {
previous_text.push(line.trim().to_string());
if previous_text.len() > 3 {
previous_text.remove(0);
}
}
continue;
};
let mut command_lines = Vec::new();
for command_line in lines.by_ref() {
if command_line.trim() == "```" {
break;
}
command_lines.push(command_line);
}
let command = command_lines.join("\n").trim().to_string();
if command.is_empty() {
continue;
}
let id = format!("cmd-{:03}", suggestions.len() + 1);
let model_risk = previous_text
.iter()
.rev()
.find_map(|line| parse_risk_marker(line));
let explanation = previous_text
.iter()
.rev()
.find(|line| !line.contains("risk:") && !line.contains("[risk:"))
.cloned();
let detected_risk = detect_command_risk(&command, shell);
suggestions.push(CommandSuggestion {
id,
shell,
command,
explanation,
model_risk,
detected_risk,
discarded: false,
});
}
suggestions
}
pub fn detect_command_risk(command: &str, shell: CommandShell) -> CommandRisk {
let lowered = command.to_ascii_lowercase();
let mut reasons = Vec::new();
if lowered.contains("rm -rf")
|| lowered.contains("rm -fr")
|| lowered.contains("remove-item") && lowered.contains("-recurse") && lowered.contains("-force")
|| lowered.contains("del /s")
{
reasons.push("recursive or forced deletion".into());
}
if lowered.contains("format-volume")
|| lowered.contains("format ")
|| lowered.contains("mkfs")
|| lowered.contains("diskpart")
{
reasons.push("disk formatting or partition operation".into());
}
if lowered.contains("chmod -r")
|| lowered.contains("chown -r")
|| lowered.contains("icacls ") && lowered.contains("/grant")
{
reasons.push("recursive permission change".into());
}
if lowered.contains("curl ") && lowered.contains("| sh")
|| lowered.contains("wget ") && lowered.contains("| sh")
|| lowered.contains("irm ") && lowered.contains("iex")
|| lowered.contains("invoke-restmethod") && lowered.contains("invoke-expression")
{
reasons.push("downloaded content piped to an interpreter".into());
}
if lowered.contains("api_key")
|| lowered.contains("apikey")
|| lowered.contains("password")
|| lowered.contains("secret")
|| lowered.contains("token")
{
reasons.push("possible credential exposure".into());
}
if lowered.contains("apt remove")
|| lowered.contains("apt purge")
|| lowered.contains("dnf remove")
|| lowered.contains("yum remove")
|| lowered.contains("pacman -r")
|| lowered.contains("uninstall-package")
{
reasons.push("package removal".into());
}
if shell == CommandShell::PowerShell && lowered.contains("set-executionpolicy") {
reasons.push("PowerShell execution policy change".into());
}
CommandRisk {
level: if reasons.is_empty() {
RiskLevel::Low
} else {
RiskLevel::High
},
reasons,
}
}
pub fn render_suggestions(suggestions: &[CommandSuggestion]) -> String {
if suggestions.is_empty() {
return String::new();
}
let mut rendered = String::from("\nSuggested command actions:\n");
for suggestion in suggestions {
rendered.push_str(&format!(
"- {} [{}]{}",
suggestion.id,
suggestion.shell,
if suggestion.discarded { " discarded" } else { "" }
));
if let Some(risk) = suggestion.model_risk {
rendered.push_str(&format!(" model_risk={risk}"));
}
if let Some(warning) = suggestion.detected_risk.warning() {
rendered.push_str(&format!(" warning=\"{warning}\""));
}
rendered.push('\n');
}
rendered.push_str("Use /copy <id>, /explain <id>, or /discard <id>. Exoshell does not execute commands.");
rendered
}
fn shell_from_fence(line: &str) -> Option<CommandShell> {
let language = line.trim().strip_prefix("```")?;
match language {
"powershell" | "pwsh" => Some(CommandShell::PowerShell),
"sh" | "bash" | "zsh" | "posix" => Some(CommandShell::Posix),
_ => None,
}
}
fn parse_risk_marker(line: &str) -> Option<RiskLevel> {
let lowered = line.to_ascii_lowercase();
if lowered.contains("risk: high") || lowered.contains("[risk: high]") {
Some(RiskLevel::High)
} else if lowered.contains("risk: medium") || lowered.contains("[risk: medium]") {
Some(RiskLevel::Medium)
} else if lowered.contains("risk: low") || lowered.contains("[risk: low]") {
Some(RiskLevel::Low)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_multiple_fenced_command_suggestions() {
let suggestions = parse_command_suggestions(
"Inspect files:\n```sh\nrg TODO\n```\n[risk: high]\nRemove build:\n```powershell\nRemove-Item -Recurse -Force target\n```",
);
assert_eq!(suggestions.len(), 2);
assert_eq!(suggestions[0].id, "cmd-001");
assert_eq!(suggestions[0].shell, CommandShell::Posix);
assert_eq!(suggestions[0].command, "rg TODO");
assert_eq!(suggestions[1].model_risk, Some(RiskLevel::High));
assert_eq!(suggestions[1].detected_risk.level, RiskLevel::High);
}
#[test]
fn invalid_or_empty_command_blocks_are_ignored() {
let suggestions = parse_command_suggestions("```text\nnot a command\n```\n```sh\n\n```");
assert!(suggestions.is_empty());
}
#[test]
fn detects_representative_destructive_commands() {
assert_eq!(
detect_command_risk("rm -rf /tmp/example", CommandShell::Posix).level,
RiskLevel::High
);
assert_eq!(
detect_command_risk(
"Invoke-RestMethod https://example.invalid/install.ps1 | Invoke-Expression",
CommandShell::PowerShell
)
.level,
RiskLevel::High
);
assert_eq!(
detect_command_risk("rg TODO", CommandShell::Posix).level,
RiskLevel::Low
);
}
}

View File

@ -6,12 +6,14 @@ use serde::Deserialize;
use crate::app::CliOptions; use crate::app::CliOptions;
use crate::context::ContextBudget; use crate::context::ContextBudget;
use crate::prompts::Stance;
use crate::shell::ShellFamily; use crate::shell::ShellFamily;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config { pub struct Config {
pub provider: ProviderConfig, pub provider: ProviderConfig,
pub shell: ShellConfig, pub shell: ShellConfig,
pub interaction: InteractionConfig,
pub transcript: TranscriptConfig, pub transcript: TranscriptConfig,
pub context: ContextConfig, pub context: ContextConfig,
} }
@ -30,6 +32,11 @@ pub struct ShellConfig {
pub family: ShellFamily, pub family: ShellFamily,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InteractionConfig {
pub stance: Stance,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptConfig { pub struct TranscriptConfig {
pub directory: PathBuf, pub directory: PathBuf,
@ -55,6 +62,7 @@ impl ContextConfig {
struct RawConfig { struct RawConfig {
provider: Option<RawProviderConfig>, provider: Option<RawProviderConfig>,
shell: Option<RawShellConfig>, shell: Option<RawShellConfig>,
interaction: Option<RawInteractionConfig>,
transcript: Option<RawTranscriptConfig>, transcript: Option<RawTranscriptConfig>,
context: Option<RawContextConfig>, context: Option<RawContextConfig>,
} }
@ -72,6 +80,11 @@ struct RawShellConfig {
family: Option<String>, family: Option<String>,
} }
#[derive(Debug, Deserialize, Default)]
struct RawInteractionConfig {
stance: Option<String>,
}
#[derive(Debug, Deserialize, Default)] #[derive(Debug, Deserialize, Default)]
struct RawTranscriptConfig { struct RawTranscriptConfig {
directory: Option<PathBuf>, directory: Option<PathBuf>,
@ -97,6 +110,7 @@ impl Config {
fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> { fn from_raw(raw: RawConfig) -> Result<Self, ConfigError> {
let provider = raw.provider.unwrap_or_default(); let provider = raw.provider.unwrap_or_default();
let shell = raw.shell.unwrap_or_default(); let shell = raw.shell.unwrap_or_default();
let interaction = raw.interaction.unwrap_or_default();
let transcript = raw.transcript.unwrap_or_default(); let transcript = raw.transcript.unwrap_or_default();
let context = raw.context.unwrap_or_default(); let context = raw.context.unwrap_or_default();
@ -112,6 +126,11 @@ impl Config {
let family = family let family = family
.parse::<ShellFamily>() .parse::<ShellFamily>()
.map_err(|error| ConfigError::Invalid(error.to_string()))?; .map_err(|error| ConfigError::Invalid(error.to_string()))?;
let stance = interaction
.stance
.unwrap_or_else(|| Stance::default().to_string())
.parse::<Stance>()
.map_err(|error| ConfigError::Invalid(error.to_string()))?;
Ok(Self { Ok(Self {
provider: ProviderConfig { provider: ProviderConfig {
@ -122,6 +141,7 @@ impl Config {
request_timeout_seconds: provider.request_timeout_seconds.unwrap_or(120), request_timeout_seconds: provider.request_timeout_seconds.unwrap_or(120),
}, },
shell: ShellConfig { family }, shell: ShellConfig { family },
interaction: InteractionConfig { stance },
transcript: TranscriptConfig { transcript: TranscriptConfig {
directory: transcript.directory.unwrap_or_else(default_transcript_dir), directory: transcript.directory.unwrap_or_else(default_transcript_dir),
enabled: transcript.enabled.unwrap_or(true), enabled: transcript.enabled.unwrap_or(true),
@ -138,6 +158,10 @@ impl Config {
self.shell.family = shell_family; self.shell.family = shell_family;
} }
if let Some(stance) = options.stance {
self.interaction.stance = stance;
}
if let Some(transcript_enabled) = options.transcript_enabled { if let Some(transcript_enabled) = options.transcript_enabled {
self.transcript.enabled = transcript_enabled; self.transcript.enabled = transcript_enabled;
} }
@ -303,6 +327,7 @@ mod tests {
shell: Some(RawShellConfig { shell: Some(RawShellConfig {
family: Some("cmd".into()), family: Some("cmd".into()),
}), }),
interaction: None,
transcript: None, transcript: None,
context: None, context: None,
}) })
@ -325,6 +350,9 @@ request_timeout_seconds = 45
[shell] [shell]
family = "posix" family = "posix"
[interaction]
stance = "audit"
[transcript] [transcript]
enabled = false enabled = false
@ -342,6 +370,7 @@ max_estimated_tokens = 3000
assert_eq!(config.provider.model, "local-model"); assert_eq!(config.provider.model, "local-model");
assert_eq!(config.provider.request_timeout_seconds, 45); assert_eq!(config.provider.request_timeout_seconds, 45);
assert_eq!(config.shell.family, ShellFamily::Posix); assert_eq!(config.shell.family, ShellFamily::Posix);
assert_eq!(config.interaction.stance, Stance::Audit);
assert!(!config.transcript.enabled); assert!(!config.transcript.enabled);
assert_eq!(config.context.max_characters, Some(12000)); assert_eq!(config.context.max_characters, Some(12000));
assert_eq!(config.context.max_estimated_tokens, Some(3000)); assert_eq!(config.context.max_estimated_tokens, Some(3000));
@ -386,6 +415,7 @@ max_estimated_tokens = 3000
let tempdir = PathBuf::from("manual-transcripts"); let tempdir = PathBuf::from("manual-transcripts");
let options = CliOptions { let options = CliOptions {
shell_family: Some(ShellFamily::Posix), shell_family: Some(ShellFamily::Posix),
stance: Some(Stance::Teach),
transcript_enabled: Some(false), transcript_enabled: Some(false),
transcript_directory: Some(tempdir.clone()), transcript_directory: Some(tempdir.clone()),
..CliOptions::default() ..CliOptions::default()
@ -394,6 +424,7 @@ max_estimated_tokens = 3000
config.apply_cli_overrides(&options).expect("overrides"); config.apply_cli_overrides(&options).expect("overrides");
assert_eq!(config.shell.family, ShellFamily::Posix); assert_eq!(config.shell.family, ShellFamily::Posix);
assert_eq!(config.interaction.stance, Stance::Teach);
assert!(!config.transcript.enabled); assert!(!config.transcript.enabled);
assert_eq!(config.transcript.directory, tempdir); assert_eq!(config.transcript.directory, tempdir);
} }

View File

@ -1,3 +1,5 @@
use crate::commands::{parse_command_suggestions, render_suggestions};
pub fn render_assistant_output(response: &str) -> String { pub fn render_assistant_output(response: &str) -> String {
let mut rendered = String::new(); let mut rendered = String::new();
let mut in_command_block = false; let mut in_command_block = false;
@ -25,6 +27,12 @@ pub fn render_assistant_output(response: &str) -> String {
rendered.push('\n'); rendered.push('\n');
} }
let suggestions = parse_command_suggestions(response);
if !suggestions.is_empty() {
rendered.push_str(&render_suggestions(&suggestions));
rendered.push('\n');
}
if response.contains("[risk: high]") || response.contains("risk: high") { if response.contains("[risk: high]") || response.contains("risk: high") {
rendered.push_str("--- review required: model marked this response as high risk ---\n"); rendered.push_str("--- review required: model marked this response as high risk ---\n");
} }

View File

@ -1,4 +1,5 @@
mod app; mod app;
mod commands;
mod config; mod config;
pub mod context; pub mod context;
mod formatting; mod formatting;

View File

@ -1,21 +1,229 @@
use std::fmt;
use std::str::FromStr;
use crate::context::{ContextBudget, ContextEntry, ContextSize, render_prompt_context};
use crate::providers::{ChatMessage, ChatRole};
use crate::shell::ShellFamily; use crate::shell::ShellFamily;
pub fn phase1_system_prompt(shell_family: ShellFamily) -> String { #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stance {
Operator,
Audit,
Teach,
Quiet,
}
impl Stance {
pub const ALL: [Stance; 4] = [Self::Operator, Self::Audit, Self::Teach, Self::Quiet];
pub fn prompt_fragment(self) -> &'static str {
match self {
Self::Operator => {
"Stance: operator.\n\
Favor concise, action-oriented help. Prioritize next steps, commands, and operational diagnosis. State uncertainty plainly without extended teaching. Mark risky suggestions clearly."
}
Self::Audit => {
"Stance: audit.\n\
Prioritize security, correctness, reliability, and operational failure modes. Avoid mutating commands unless explicitly requested. Prioritize findings by severity when applicable. Distinguish evidence from inference."
}
Self::Teach => {
"Stance: teach.\n\
Explain commands and concepts more fully while staying usable in a terminal. Explain flags and expected output when helpful. Do not assume shell-specific behavior is already known."
}
Self::Quiet => {
"Stance: quiet.\n\
Minimize prose. Prefer direct commands and short rationale. Keep destructive-command and safety warnings visible."
}
}
}
pub fn names() -> String {
Self::ALL
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
}
impl Default for Stance {
fn default() -> Self {
Self::Operator
}
}
impl fmt::Display for Stance {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Operator => formatter.write_str("operator"),
Self::Audit => formatter.write_str("audit"),
Self::Teach => formatter.write_str("teach"),
Self::Quiet => formatter.write_str("quiet"),
}
}
}
impl FromStr for Stance {
type Err = StanceError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"operator" => Ok(Self::Operator),
"audit" => Ok(Self::Audit),
"teach" => Ok(Self::Teach),
"quiet" => Ok(Self::Quiet),
other => Err(StanceError::Unknown(other.to_string())),
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum StanceError {
#[error("unknown stance '{0}', expected operator, audit, teach, or quiet")]
Unknown(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PromptBudgetEstimate {
pub system: ContextSize,
pub history: ContextSize,
pub context: ContextSize,
pub total: ContextSize,
pub budget: ContextBudget,
}
impl PromptBudgetEstimate {
pub fn is_context_over_budget(&self) -> bool {
self.budget.is_over_budget(self.context)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptAssembly {
pub messages: Vec<ChatMessage>,
pub estimate: PromptBudgetEstimate,
}
pub fn phase2_system_prompt(shell_family: ShellFamily, stance: Stance) -> String {
format!( format!(
"You are Exoshell, a shell-adjacent assistant for technical operators.\n\ "You are Exoshell, a shell-adjacent assistant for technical operators.\n\
Sacred rule: enhance skill; do not replace it.\n\ Sacred rule: enhance skill; do not replace it.\n\
The human keeps the controls. Suggest commands, but never say or imply that you executed them.\n\ The human keeps the controls. Suggest commands, but never say or imply that you executed them.\n\
Do not request or perform autonomous execution. Do not normalize blind destructive commands.\n\ Do not request or perform autonomous execution. Do not normalize blind destructive commands.\n\
Context is explicit, operator-selected, and session-scoped. Treat attached context as visible working material, not hidden memory.\n\
Prefer deterministic shell tools when they are the right fit; do not force AI into workflows where awk, sed, jq, rg, git, cargo, make, or platform-native tools are better.\n\ Prefer deterministic shell tools when they are the right fit; do not force AI into workflows where awk, sed, jq, rg, git, cargo, make, or platform-native tools are better.\n\
Surface uncertainty with signal language such as 'signal: weak', 'signal: medium', or 'signal: high' when confidence matters.\n\ Surface uncertainty with signal language such as 'signal: weak', 'signal: medium', or 'signal: high' when confidence matters.\n\
Target shell family: {shell_family}.\n\ Target shell family: {shell_family}.\n\
Shell instructions: {}\n\ Shell instructions: {}\n\
Command convention: put suggested commands in fenced code blocks using language tag `{}`. Add a short review note before risky operations. For destructive commands, require explicit user review and provide a safer inspection command first when possible.", Command convention: put suggested commands in fenced code blocks using language tag `{}`. Add a short review note before risky operations. For destructive commands, require explicit user review and provide a safer inspection command first when possible.\n\
{}",
shell_family.prompt_instructions(), shell_family.prompt_instructions(),
command_language(shell_family) command_language(shell_family),
stance.prompt_fragment()
) )
} }
pub fn assemble_prompt(
shell_family: ShellFamily,
stance: Stance,
conversation: &[ChatMessage],
context_entries: &[ContextEntry],
budget: ContextBudget,
) -> PromptAssembly {
let system_prompt = phase2_system_prompt(shell_family, stance);
let rendered_context = render_prompt_context(context_entries);
let estimate = estimate_prompt(&system_prompt, conversation, &rendered_context, budget);
let mut messages = Vec::with_capacity(conversation.len() + 2);
messages.push(ChatMessage::new(ChatRole::System, system_prompt));
if rendered_context.is_empty() {
messages.extend_from_slice(conversation);
} else {
let insert_at = conversation.len().saturating_sub(1);
messages.extend_from_slice(&conversation[..insert_at]);
messages.push(ChatMessage::new(
ChatRole::User,
format!(
"Explicit session context selected by the operator follows.\n\n{}",
rendered_context
),
));
messages.extend_from_slice(&conversation[insert_at..]);
}
PromptAssembly { messages, estimate }
}
pub fn estimate_prompt(
system_prompt: &str,
conversation: &[ChatMessage],
rendered_context: &str,
budget: ContextBudget,
) -> PromptBudgetEstimate {
let system = ContextSize::from_content(system_prompt);
let history = ContextSize::from_content(
&conversation
.iter()
.map(|message| message.content.as_str())
.collect::<Vec<_>>()
.join("\n"),
);
let context = ContextSize::from_content(rendered_context);
let total = ContextSize {
characters: system.characters + history.characters + context.characters,
estimated_tokens: system.estimated_tokens
+ history.estimated_tokens
+ context.estimated_tokens,
};
PromptBudgetEstimate {
system,
history,
context,
total,
budget,
}
}
pub fn render_prompt_estimate(estimate: PromptBudgetEstimate) -> String {
let mut rendered = String::new();
rendered.push_str(&format!(
"system: {} chars / ~{} tokens\n",
estimate.system.characters, estimate.system.estimated_tokens
));
rendered.push_str(&format!(
"history: {} chars / ~{} tokens\n",
estimate.history.characters, estimate.history.estimated_tokens
));
rendered.push_str(&format!(
"attached_context: {} chars / ~{} tokens\n",
estimate.context.characters, estimate.context.estimated_tokens
));
rendered.push_str(&format!(
"estimated_total: {} chars / ~{} tokens",
estimate.total.characters, estimate.total.estimated_tokens
));
if let Some(max) = estimate.budget.max_characters {
rendered.push_str(&format!(
"\ncontext_character_budget: {}/{}",
estimate.context.characters, max
));
}
if let Some(max) = estimate.budget.max_estimated_tokens {
rendered.push_str(&format!(
"\ncontext_token_budget: {}/{}",
estimate.context.estimated_tokens, max
));
}
if estimate.is_context_over_budget() {
rendered.push_str("\nwarning: attached context exceeds configured budget");
}
rendered
}
fn command_language(shell_family: ShellFamily) -> &'static str { fn command_language(shell_family: ShellFamily) -> &'static str {
match shell_family { match shell_family {
ShellFamily::PowerShell => "powershell", ShellFamily::PowerShell => "powershell",
@ -26,25 +234,80 @@ fn command_language(shell_family: ShellFamily) -> &'static str {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::context::{ContextEntry, ContextKind, ContextProvenance};
#[test] #[test]
fn powershell_prompt_contains_shell_specific_instructions() { fn stance_names_parse_and_display() {
let prompt = phase1_system_prompt(ShellFamily::PowerShell); assert_eq!("audit".parse::<Stance>().expect("stance"), Stance::Audit);
assert_eq!(Stance::names(), "operator, audit, teach, quiet");
assert!("loud".parse::<Stance>().is_err());
}
#[test]
fn system_prompt_contains_stance_and_shell_specific_instructions() {
let prompt = phase2_system_prompt(ShellFamily::PowerShell, Stance::Audit);
assert!(prompt.contains("Target shell family: powershell")); assert!(prompt.contains("Target shell family: powershell"));
assert!(prompt.contains("Use PowerShell syntax")); assert!(prompt.contains("Use PowerShell syntax"));
assert!(prompt.contains("Stance: audit"));
assert!(prompt.contains("Distinguish evidence from inference"));
assert!(prompt.contains("never say or imply that you executed")); assert!(prompt.contains("never say or imply that you executed"));
assert!(prompt.contains("signal: weak"));
assert!(prompt.contains("fenced code blocks using language tag `powershell`")); assert!(prompt.contains("fenced code blocks using language tag `powershell`"));
} }
#[test] #[test]
fn posix_prompt_contains_shell_specific_instructions() { fn quiet_stance_keeps_safety_language() {
let prompt = phase1_system_prompt(ShellFamily::Posix); let prompt = phase2_system_prompt(ShellFamily::Posix, Stance::Quiet);
assert!(prompt.contains("Target shell family: posix")); assert!(prompt.contains("Stance: quiet"));
assert!(prompt.contains("bash or zsh")); assert!(prompt.contains("Keep destructive-command and safety warnings visible"));
assert!(prompt.contains("Prefer deterministic shell tools"));
assert!(prompt.contains("fenced code blocks using language tag `sh`")); assert!(prompt.contains("fenced code blocks using language tag `sh`"));
} }
#[test]
fn prompt_assembly_orders_system_history_context_and_current_input() {
let context = vec![ContextEntry::new(
"ctx-001",
ContextKind::Manual,
"note",
ContextProvenance::manual(),
"repo uses cargo",
)];
let conversation = vec![
ChatMessage::new(ChatRole::User, "first"),
ChatMessage::new(ChatRole::Assistant, "answer"),
ChatMessage::new(ChatRole::User, "current"),
];
let assembly = assemble_prompt(
ShellFamily::Posix,
Stance::Operator,
&conversation,
&context,
ContextBudget::default(),
);
assert_eq!(assembly.messages[0].role, ChatRole::System);
assert!(assembly.messages[0].content.contains("Stance: operator"));
assert_eq!(assembly.messages[1].content, "first");
assert_eq!(assembly.messages[2].content, "answer");
assert!(assembly.messages[3].content.contains("[Context: ctx-001]"));
assert_eq!(assembly.messages[4].content, "current");
assert!(assembly.estimate.total.characters > assembly.estimate.context.characters);
}
#[test]
fn prompt_estimate_reports_context_budget_warning() {
let estimate = estimate_prompt(
"system",
&[ChatMessage::new(ChatRole::User, "hello")],
"large context",
ContextBudget {
max_characters: Some(3),
max_estimated_tokens: None,
},
);
assert!(render_prompt_estimate(estimate).contains("warning"));
}
} }

View File

@ -13,9 +13,10 @@ impl Repl {
} }
pub async fn run(mut self) -> Result<(), AppError> { pub async fn run(mut self) -> Result<(), AppError> {
println!("Exoshell Phase 1"); println!("Exoshell Phase 2");
println!("stance: {}", self.app.stance());
println!( println!(
"Type /exit to quit. Type /multi to enter multi-line input, then finish with a single '.' line." "Type /exit to quit. Type /stance to inspect or change stance. Type /multi to enter multi-line input, then finish with a single '.' line."
); );
loop { loop {
@ -45,6 +46,12 @@ impl Repl {
} }
if input.starts_with("/context") if input.starts_with("/context")
|| input.starts_with("/stance")
|| input.starts_with("/copy ")
|| input.starts_with("/explain ")
|| input.starts_with("/discard ")
|| input.starts_with("/help")
|| input == "/panel"
|| input.starts_with("/add-note ") || input.starts_with("/add-note ")
|| input.starts_with("/add-file ") || input.starts_with("/add-file ")
|| input.starts_with("/add-dir ") || input.starts_with("/add-dir ")

View File

@ -2,7 +2,9 @@ use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::commands::CommandSuggestion;
use crate::context::{ContextEntry, redacted_provider_details}; use crate::context::{ContextEntry, redacted_provider_details};
use crate::prompts::Stance;
use crate::shell::ShellFamily; use crate::shell::ShellFamily;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -11,16 +13,18 @@ pub struct Transcript {
provider: String, provider: String,
model: String, model: String,
shell_family: ShellFamily, shell_family: ShellFamily,
stance: Stance,
entries: Vec<TranscriptEntry>, entries: Vec<TranscriptEntry>,
} }
impl Transcript { impl Transcript {
pub fn new(provider: String, model: String, shell_family: ShellFamily) -> Self { pub fn new(provider: String, model: String, shell_family: ShellFamily, stance: Stance) -> Self {
Self { Self {
started_at_epoch_ms: unix_millis(), started_at_epoch_ms: unix_millis(),
provider, provider,
model, model,
shell_family, shell_family,
stance,
entries: Vec::new(), entries: Vec::new(),
} }
} }
@ -62,6 +66,33 @@ impl Transcript {
.push(TranscriptEntry::BudgetWarning(warning.to_string())); .push(TranscriptEntry::BudgetWarning(warning.to_string()));
} }
pub fn record_stance_change(&mut self, previous: Stance, current: Stance) {
self.stance = current;
self.entries.push(TranscriptEntry::StanceChange {
previous: previous.to_string(),
current: current.to_string(),
});
}
pub fn record_command_suggestion(&mut self, suggestion: &CommandSuggestion) {
self.entries.push(TranscriptEntry::CommandSuggestion {
id: suggestion.id.clone(),
shell: suggestion.shell.to_string(),
command: suggestion.command.clone(),
model_risk: suggestion.model_risk.map(|risk| risk.to_string()),
detected_risk: suggestion.detected_risk.level.to_string(),
risk_reasons: suggestion.detected_risk.reasons.clone(),
});
}
pub fn record_command_action(&mut self, id: &str, action: &str, note: &str) {
self.entries.push(TranscriptEntry::CommandAction {
id: id.to_string(),
action: action.to_string(),
note: note.to_string(),
});
}
pub fn write_to_dir(&self, directory: &Path) -> Result<PathBuf, TranscriptError> { pub fn write_to_dir(&self, directory: &Path) -> Result<PathBuf, TranscriptError> {
fs::create_dir_all(directory).map_err(|error| TranscriptError::CreateDir { fs::create_dir_all(directory).map_err(|error| TranscriptError::CreateDir {
path: directory.to_path_buf(), path: directory.to_path_buf(),
@ -83,12 +114,13 @@ impl Transcript {
fn to_markdown(&self) -> String { fn to_markdown(&self) -> String {
let mut markdown = format!( let mut markdown = format!(
"# Exoshell Session {}\n\n- started_at_epoch_ms: `{}`\n- provider: `{}`\n- model: `{}`\n- shell_family: `{}`\n\n", "# Exoshell Session {}\n\n- started_at_epoch_ms: `{}`\n- provider: `{}`\n- model: `{}`\n- shell_family: `{}`\n- stance: `{}`\n\n",
self.started_at_epoch_ms, self.started_at_epoch_ms,
self.started_at_epoch_ms, self.started_at_epoch_ms,
self.provider, self.provider,
self.model, self.model,
self.shell_family self.shell_family,
self.stance
); );
for entry in &self.entries { for entry in &self.entries {
@ -144,6 +176,39 @@ impl Transcript {
markdown.push_str(content); markdown.push_str(content);
markdown.push_str("\n\n"); markdown.push_str("\n\n");
} }
TranscriptEntry::StanceChange { previous, current } => {
markdown.push_str("## Stance Change\n\n");
markdown.push_str(&format!("- previous: `{previous}`\n"));
markdown.push_str(&format!("- current: `{current}`\n\n"));
}
TranscriptEntry::CommandSuggestion {
id,
shell,
command,
model_risk,
detected_risk,
risk_reasons,
} => {
markdown.push_str("## Command Suggestion\n\n");
markdown.push_str(&format!("- id: `{id}`\n"));
markdown.push_str(&format!("- shell: `{shell}`\n"));
if let Some(model_risk) = model_risk {
markdown.push_str(&format!("- model_risk: `{model_risk}`\n"));
}
markdown.push_str(&format!("- detected_risk: `{detected_risk}`\n"));
for reason in risk_reasons {
markdown.push_str(&format!("- risk_reason: `{reason}`\n"));
}
markdown.push_str("\n```text\n");
markdown.push_str(command);
markdown.push_str("\n```\n\n");
}
TranscriptEntry::CommandAction { id, action, note } => {
markdown.push_str("## Command Action\n\n");
markdown.push_str(&format!("- id: `{id}`\n"));
markdown.push_str(&format!("- action: `{action}`\n"));
markdown.push_str(&format!("- note: `{note}`\n\n"));
}
} }
} }
@ -171,6 +236,20 @@ enum TranscriptEntry {
note: String, note: String,
}, },
BudgetWarning(String), BudgetWarning(String),
StanceChange { previous: String, current: String },
CommandSuggestion {
id: String,
shell: String,
command: String,
model_risk: Option<String>,
detected_risk: String,
risk_reasons: Vec<String>,
},
CommandAction {
id: String,
action: String,
note: String,
},
} }
fn unix_millis() -> u128 { fn unix_millis() -> u128 {
@ -208,6 +287,7 @@ mod tests {
"test-provider".into(), "test-provider".into(),
"test-model".into(), "test-model".into(),
ShellFamily::Posix, ShellFamily::Posix,
Stance::Operator,
); );
transcript.record_user("hello"); transcript.record_user("hello");
transcript.record_assistant("hi"); transcript.record_assistant("hi");
@ -221,6 +301,7 @@ mod tests {
assert!(contents.contains("test-model")); assert!(contents.contains("test-model"));
assert!(contents.contains("test-provider")); assert!(contents.contains("test-provider"));
assert!(contents.contains("shell_family: `posix`")); assert!(contents.contains("shell_family: `posix`"));
assert!(contents.contains("stance: `operator`"));
assert!(contents.contains("## User")); assert!(contents.contains("## User"));
assert!(contents.contains("hello")); assert!(contents.contains("hello"));
assert!(contents.contains("## Assistant")); assert!(contents.contains("## Assistant"));
@ -234,6 +315,7 @@ mod tests {
"test-provider".into(), "test-provider".into(),
"test-model".into(), "test-model".into(),
ShellFamily::Posix, ShellFamily::Posix,
Stance::Operator,
); );
let mut provenance = ContextProvenance::manual(); let mut provenance = ContextProvenance::manual();
provenance provenance
@ -249,6 +331,7 @@ mod tests {
transcript.record_context_event("add", &entry, "added"); transcript.record_context_event("add", &entry, "added");
transcript.record_budget_warning("context budget exceeded"); transcript.record_budget_warning("context budget exceeded");
transcript.record_stance_change(Stance::Operator, Stance::Audit);
let path = transcript let path = transcript
.write_to_dir(tempdir.path()) .write_to_dir(tempdir.path())
@ -261,5 +344,7 @@ mod tests {
assert!(contents.contains("api_key: `[redacted]`")); assert!(contents.contains("api_key: `[redacted]`"));
assert!(!contents.contains("payload should not appear")); assert!(!contents.contains("payload should not appear"));
assert!(contents.contains("## Context Budget Warning")); assert!(contents.contains("## Context Budget Warning"));
assert!(contents.contains("## Stance Change"));
assert!(contents.contains("current: `audit`"));
} }
} }