mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
Stances and phase 2 start
This commit is contained in:
parent
c084e2f3c0
commit
c552a93a9a
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -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.
|
||||||
|
|
|
||||||
15
docs/BUGS.md
15
docs/BUGS.md
|
|
@ -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.
|
||||||
130
docs/phase2_interaction_model.md
Normal file
130
docs/phase2_interaction_model.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
318
src/app.rs
318
src/app.rs
|
|
@ -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(),
|
||||||
|
self.config.context.budget(),
|
||||||
|
)
|
||||||
|
.estimate
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stance(&self) -> Stance {
|
||||||
|
self.config.interaction.stance
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stance_status(&self) -> String {
|
||||||
format!(
|
format!(
|
||||||
"Explicit session context selected by the operator follows.\n\n{}",
|
"current stance: {}\navailable stances: {}",
|
||||||
context
|
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
283
src/commands.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
mod formatting;
|
mod formatting;
|
||||||
|
|
|
||||||
285
src/prompts.rs
285
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;
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
src/repl.rs
11
src/repl.rs
|
|
@ -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 ")
|
||||||
|
|
|
||||||
|
|
@ -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`"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user