diff --git a/Cargo.lock b/Cargo.lock index 9d209e9..7d2b3bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ dependencies = [ [[package]] name = "exoshell" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index 03b8fb4..d31759b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "exoshell" -version = "0.2.0" +version = "0.3.0" edition = "2024" license = "GPL-3.0-or-later" diff --git a/README.md b/README.md index 9633d06..eff2266 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,13 @@ Sacred rule: enhance skill; do not replace it. ## 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 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). @@ -55,6 +55,12 @@ cargo run -- --shell powershell cargo run -- --shell posix ``` +Select an operating stance: + +```sh +cargo run -- --stance audit +``` + Exoshell suggests commands. It does not execute them. ## 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. - [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. - [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. diff --git a/docs/BUGS.md b/docs/BUGS.md index e69de29..7e5aca1 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -0,0 +1,15 @@ +# Known Bugs and Limitations + +## Phase 2 command controls + +Clipboard integration is not implemented. `/copy ` 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. diff --git a/docs/phase2_interaction_model.md b/docs/phase2_interaction_model.md new file mode 100644 index 0000000..6f56fc3 --- /dev/null +++ b/docs/phase2_interaction_model.md @@ -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. diff --git a/docs/versioning.md b/docs/versioning.md index 5b53ea5..4f10b23 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -101,3 +101,4 @@ Historical codenames should be tracked in docs/versioning.md below * 0.1.0 packet-kobold * 0.2.0 context-relic +* 0.3.0 stance-lantern diff --git a/src/app.rs b/src/app.rs index 66e5aa2..839ae10 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,14 @@ use std::path::PathBuf; use std::time::Duration; +use crate::commands::{CommandSuggestion, parse_command_suggestions}; use crate::config::Config; use crate::context::{ ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest, SessionContextStore, budget_warning, prune_context, register_default_context_providers, - render_context_details, render_context_list, render_context_stats, 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::repl::ReplError; use crate::shell::ShellFamily; @@ -16,10 +17,11 @@ use crate::transcripts::{Transcript, TranscriptError}; pub struct App { config: Config, provider: Box, - messages: Vec, + conversation: Vec, transcript: Transcript, context_store: SessionContextStore, context_registry: ContextProviderRegistry, + last_command_suggestions: Vec, } impl App { @@ -28,11 +30,8 @@ impl App { "openai-compatible".into(), config.provider.model.clone(), 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(); register_default_context_providers(&mut context_registry) .expect("default context providers should register"); @@ -40,15 +39,16 @@ impl App { Self { config, provider, - messages, + conversation: Vec::new(), transcript, context_store: SessionContextStore::new(), context_registry, + last_command_suggestions: Vec::new(), } } pub async fn send(&mut self, input: String) -> Result { - self.messages + self.conversation .push(ChatMessage::new(ChatRole::User, input.clone())); self.transcript.record_user(&input); @@ -75,9 +75,13 @@ impl App { return Err(error.into()); } }; - self.messages + self.conversation .push(ChatMessage::new(ChatRole::Assistant, response.clone())); 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) } @@ -88,11 +92,45 @@ impl App { 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" { + let prompt_estimate = self.prompt_budget_estimate(); return Ok(render_context_stats( self.context_store.stats(), 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 ") { @@ -249,24 +287,116 @@ impl App { return Err(ContextError::TooLarge(warning).into()); } - let context = render_prompt_context(self.context_store.entries()); - if context.is_empty() { - return Ok(self.messages.clone()); - } + Ok(assemble_prompt( + self.config.shell.family, + self.config.interaction.stance, + &self.conversation, + self.context_store.entries(), + budget, + ) + .messages) + } - let mut messages = self.messages.clone(); - let insert_at = messages.len().saturating_sub(1); - messages.insert( - insert_at, - ChatMessage::new( - ChatRole::User, - format!( - "Explicit session context selected by the operator follows.\n\n{}", - context - ), - ), + fn prompt_budget_estimate(&self) -> crate::prompts::PromptBudgetEstimate { + assemble_prompt( + self.config.shell.family, + self.config.interaction.stance, + &self.conversation, + self.context_store.entries(), + self.config.context.budget(), + ) + .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 { + let stance = input + .parse::() + .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 { + 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 { + 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 { + { + 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( @@ -365,10 +495,49 @@ fn parse_context_priority_args(input: &str) -> Result<(&str, ContextPriority), C Ok((id, priority)) } +fn help_overview() -> &'static str { + "Commands: +/context list attached context +/context stats show context and prompt budget estimates +/context show inspect a context entry +/context enable|disable control model inclusion +/context pin|unpin control pruning preference +/context priority set low, normal, high, or critical priority +/add-note attach manual context +/add-file attach a UTF-8 file +/add-dir attach a shallow directory summary +/add-output paste command output as explicit context +/stance [name] show or set operator, audit, teach, or quiet +/copy print a suggested command; does not execute it +/explain explain a suggested command +/discard 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)] pub struct CliOptions { pub config_path: Option, pub shell_family: Option, + pub stance: Option, pub transcript_enabled: Option, pub transcript_directory: Option, pub context_notes: Vec, @@ -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), "--transcript-dir" => { let value = args.next().ok_or_else(|| { @@ -440,14 +618,14 @@ impl CliOptions { } pub fn help() -> &'static str { - "Usage: exoshell [--config ] [--shell powershell|posix] [--context-note ] [--context-file ] [--no-transcript] [--transcript-dir ] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them." + "Usage: exoshell [--config ] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--context-note ] [--context-file ] [--no-transcript] [--transcript-dir ] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them." } } #[cfg(test)] mod tests { use super::*; - use crate::config::{ProviderConfig, ShellConfig, TranscriptConfig}; + use crate::config::{InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig}; use std::sync::{Arc, Mutex}; #[test] @@ -470,6 +648,8 @@ mod tests { let options = CliOptions::parse([ "--shell".to_string(), "posix".to_string(), + "--stance".to_string(), + "audit".to_string(), "--no-transcript".to_string(), "--transcript-dir".to_string(), "out".to_string(), @@ -482,6 +662,7 @@ mod tests { .expect("options parse"); 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_directory, Some(PathBuf::from("out"))); assert_eq!(options.context_notes, vec!["note".to_string()]); @@ -551,6 +732,11 @@ mod tests { .expect("stats") .contains("total_entries: 1") ); + assert!( + app.handle_command("/context stats") + .expect("stats") + .contains("Prompt estimate:") + ); assert_eq!( app.handle_command("/context remove ctx-001") .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] async fn enabled_context_is_inserted_before_current_user_prompt() { 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] async fn over_budget_context_fails_before_provider_request() { 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 { + Ok(ChatResponse::Complete(self.response.clone())) + } + } + fn test_config() -> Config { Config { provider: ProviderConfig { @@ -693,6 +954,9 @@ mod tests { shell: ShellConfig { family: ShellFamily::PowerShell, }, + interaction: InteractionConfig { + stance: Stance::Operator, + }, transcript: TranscriptConfig { directory: PathBuf::from("transcripts"), enabled: false, diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..eec6699 --- /dev/null +++ b/src/commands.rs @@ -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, + pub model_risk: Option, + 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, +} + +impl CommandRisk { + pub fn none() -> Self { + Self { + level: RiskLevel::Low, + reasons: Vec::new(), + } + } + + pub fn warning(&self) -> Option { + if self.reasons.is_empty() { + None + } else { + Some(format!( + "review required: {}", + self.reasons.join("; ") + )) + } + } +} + +pub fn parse_command_suggestions(response: &str) -> Vec { + 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 , /explain , or /discard . Exoshell does not execute commands."); + rendered +} + +fn shell_from_fence(line: &str) -> Option { + 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 { + 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 + ); + } +} diff --git a/src/config.rs b/src/config.rs index 3498dbc..564306f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,12 +6,14 @@ use serde::Deserialize; use crate::app::CliOptions; use crate::context::ContextBudget; +use crate::prompts::Stance; use crate::shell::ShellFamily; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { pub provider: ProviderConfig, pub shell: ShellConfig, + pub interaction: InteractionConfig, pub transcript: TranscriptConfig, pub context: ContextConfig, } @@ -30,6 +32,11 @@ pub struct ShellConfig { pub family: ShellFamily, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InteractionConfig { + pub stance: Stance, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct TranscriptConfig { pub directory: PathBuf, @@ -55,6 +62,7 @@ impl ContextConfig { struct RawConfig { provider: Option, shell: Option, + interaction: Option, transcript: Option, context: Option, } @@ -72,6 +80,11 @@ struct RawShellConfig { family: Option, } +#[derive(Debug, Deserialize, Default)] +struct RawInteractionConfig { + stance: Option, +} + #[derive(Debug, Deserialize, Default)] struct RawTranscriptConfig { directory: Option, @@ -97,6 +110,7 @@ impl Config { fn from_raw(raw: RawConfig) -> Result { let provider = raw.provider.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 context = raw.context.unwrap_or_default(); @@ -112,6 +126,11 @@ impl Config { let family = family .parse::() .map_err(|error| ConfigError::Invalid(error.to_string()))?; + let stance = interaction + .stance + .unwrap_or_else(|| Stance::default().to_string()) + .parse::() + .map_err(|error| ConfigError::Invalid(error.to_string()))?; Ok(Self { provider: ProviderConfig { @@ -122,6 +141,7 @@ impl Config { request_timeout_seconds: provider.request_timeout_seconds.unwrap_or(120), }, shell: ShellConfig { family }, + interaction: InteractionConfig { stance }, transcript: TranscriptConfig { directory: transcript.directory.unwrap_or_else(default_transcript_dir), enabled: transcript.enabled.unwrap_or(true), @@ -138,6 +158,10 @@ impl Config { self.shell.family = shell_family; } + if let Some(stance) = options.stance { + self.interaction.stance = stance; + } + if let Some(transcript_enabled) = options.transcript_enabled { self.transcript.enabled = transcript_enabled; } @@ -303,6 +327,7 @@ mod tests { shell: Some(RawShellConfig { family: Some("cmd".into()), }), + interaction: None, transcript: None, context: None, }) @@ -325,6 +350,9 @@ request_timeout_seconds = 45 [shell] family = "posix" +[interaction] +stance = "audit" + [transcript] enabled = false @@ -342,6 +370,7 @@ max_estimated_tokens = 3000 assert_eq!(config.provider.model, "local-model"); assert_eq!(config.provider.request_timeout_seconds, 45); assert_eq!(config.shell.family, ShellFamily::Posix); + assert_eq!(config.interaction.stance, Stance::Audit); assert!(!config.transcript.enabled); assert_eq!(config.context.max_characters, Some(12000)); assert_eq!(config.context.max_estimated_tokens, Some(3000)); @@ -386,6 +415,7 @@ max_estimated_tokens = 3000 let tempdir = PathBuf::from("manual-transcripts"); let options = CliOptions { shell_family: Some(ShellFamily::Posix), + stance: Some(Stance::Teach), transcript_enabled: Some(false), transcript_directory: Some(tempdir.clone()), ..CliOptions::default() @@ -394,6 +424,7 @@ max_estimated_tokens = 3000 config.apply_cli_overrides(&options).expect("overrides"); assert_eq!(config.shell.family, ShellFamily::Posix); + assert_eq!(config.interaction.stance, Stance::Teach); assert!(!config.transcript.enabled); assert_eq!(config.transcript.directory, tempdir); } diff --git a/src/formatting.rs b/src/formatting.rs index ca501a7..1a1658f 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -1,3 +1,5 @@ +use crate::commands::{parse_command_suggestions, render_suggestions}; + pub fn render_assistant_output(response: &str) -> String { let mut rendered = String::new(); let mut in_command_block = false; @@ -25,6 +27,12 @@ pub fn render_assistant_output(response: &str) -> String { 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") { rendered.push_str("--- review required: model marked this response as high risk ---\n"); } diff --git a/src/main.rs b/src/main.rs index e967c4a..14b823f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod commands; mod config; pub mod context; mod formatting; diff --git a/src/prompts.rs b/src/prompts.rs index bf2a4f1..720a573 100644 --- a/src/prompts.rs +++ b/src/prompts.rs @@ -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; -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::>() + .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 { + 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, + pub estimate: PromptBudgetEstimate, +} + +pub fn phase2_system_prompt(shell_family: ShellFamily, stance: Stance) -> String { format!( "You are Exoshell, a shell-adjacent assistant for technical operators.\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\ 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\ Surface uncertainty with signal language such as 'signal: weak', 'signal: medium', or 'signal: high' when confidence matters.\n\ Target shell family: {shell_family}.\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(), - 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::>() + .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 { match shell_family { ShellFamily::PowerShell => "powershell", @@ -26,25 +234,80 @@ fn command_language(shell_family: ShellFamily) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::context::{ContextEntry, ContextKind, ContextProvenance}; #[test] - fn powershell_prompt_contains_shell_specific_instructions() { - let prompt = phase1_system_prompt(ShellFamily::PowerShell); + fn stance_names_parse_and_display() { + assert_eq!("audit".parse::().expect("stance"), Stance::Audit); + assert_eq!(Stance::names(), "operator, audit, teach, quiet"); + assert!("loud".parse::().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("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("signal: weak")); assert!(prompt.contains("fenced code blocks using language tag `powershell`")); } #[test] - fn posix_prompt_contains_shell_specific_instructions() { - let prompt = phase1_system_prompt(ShellFamily::Posix); + fn quiet_stance_keeps_safety_language() { + let prompt = phase2_system_prompt(ShellFamily::Posix, Stance::Quiet); - assert!(prompt.contains("Target shell family: posix")); - assert!(prompt.contains("bash or zsh")); - assert!(prompt.contains("Prefer deterministic shell tools")); + assert!(prompt.contains("Stance: quiet")); + assert!(prompt.contains("Keep destructive-command and safety warnings visible")); 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")); + } } diff --git a/src/repl.rs b/src/repl.rs index 5c4013d..5a7817b 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -13,9 +13,10 @@ impl Repl { } pub async fn run(mut self) -> Result<(), AppError> { - println!("Exoshell Phase 1"); + println!("Exoshell Phase 2"); + println!("stance: {}", self.app.stance()); 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 { @@ -45,6 +46,12 @@ impl Repl { } 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-file ") || input.starts_with("/add-dir ") diff --git a/src/transcripts.rs b/src/transcripts.rs index f0883b3..74e2c94 100644 --- a/src/transcripts.rs +++ b/src/transcripts.rs @@ -2,7 +2,9 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; +use crate::commands::CommandSuggestion; use crate::context::{ContextEntry, redacted_provider_details}; +use crate::prompts::Stance; use crate::shell::ShellFamily; #[derive(Debug, Clone)] @@ -11,16 +13,18 @@ pub struct Transcript { provider: String, model: String, shell_family: ShellFamily, + stance: Stance, entries: Vec, } 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 { started_at_epoch_ms: unix_millis(), provider, model, shell_family, + stance, entries: Vec::new(), } } @@ -62,6 +66,33 @@ impl Transcript { .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 { fs::create_dir_all(directory).map_err(|error| TranscriptError::CreateDir { path: directory.to_path_buf(), @@ -83,12 +114,13 @@ impl Transcript { fn to_markdown(&self) -> String { 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.provider, self.model, - self.shell_family + self.shell_family, + self.stance ); for entry in &self.entries { @@ -144,6 +176,39 @@ impl Transcript { markdown.push_str(content); 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, }, BudgetWarning(String), + StanceChange { previous: String, current: String }, + CommandSuggestion { + id: String, + shell: String, + command: String, + model_risk: Option, + detected_risk: String, + risk_reasons: Vec, + }, + CommandAction { + id: String, + action: String, + note: String, + }, } fn unix_millis() -> u128 { @@ -208,6 +287,7 @@ mod tests { "test-provider".into(), "test-model".into(), ShellFamily::Posix, + Stance::Operator, ); transcript.record_user("hello"); transcript.record_assistant("hi"); @@ -221,6 +301,7 @@ mod tests { assert!(contents.contains("test-model")); assert!(contents.contains("test-provider")); assert!(contents.contains("shell_family: `posix`")); + assert!(contents.contains("stance: `operator`")); assert!(contents.contains("## User")); assert!(contents.contains("hello")); assert!(contents.contains("## Assistant")); @@ -234,6 +315,7 @@ mod tests { "test-provider".into(), "test-model".into(), ShellFamily::Posix, + Stance::Operator, ); let mut provenance = ContextProvenance::manual(); provenance @@ -249,6 +331,7 @@ mod tests { transcript.record_context_event("add", &entry, "added"); transcript.record_budget_warning("context budget exceeded"); + transcript.record_stance_change(Stance::Operator, Stance::Audit); let path = transcript .write_to_dir(tempdir.path()) @@ -261,5 +344,7 @@ mod tests { assert!(contents.contains("api_key: `[redacted]`")); assert!(!contents.contains("payload should not appear")); assert!(contents.contains("## Context Budget Warning")); + assert!(contents.contains("## Stance Change")); + assert!(contents.contains("current: `audit`")); } }