Skip to main content

oxi/
lib.rs

1#![warn(missing_docs)]
2#![warn(clippy::unwrap_used)]
3#![allow(unknown_lints)]
4
5//! oxi: CLI coding harness
6//!
7//! This crate provides the main application logic for the oxi CLI.
8
9// ─── Root-level entry modules ───────────────────────────────────────────────
10// cli must be pub for main.rs binary
11pub mod cli;
12pub mod print_mode;
13pub mod setup_wizard;
14
15// ─── Directory groups ───────────────────────────────────────────────────────
16pub(crate) mod app;
17pub(crate) mod context;
18pub mod extensions; // public for main.rs
19pub(crate) mod infra;
20pub(crate) mod media;
21pub(crate) mod prompt;
22pub(crate) mod rpc_mode;
23pub(crate) mod skills;
24pub mod storage; // public for main.rs (packages)
25                 // Re-exports from storage for main.rs
26pub use storage::packages::PackageManager;
27pub use storage::packages::ResourceKind;
28pub mod tui; // public for main.rs
29pub(crate) mod ui;
30pub(crate) mod util;
31
32// ─── oxi-store re-exports (shared persistent state) ─────────────────────────
33pub use oxi_store::{
34    auth_guidance, auth_storage, model_registry, model_resolver, session, session_cwd,
35    session_navigation, settings, settings_validation, AgentMessage, AssistantContentBlock,
36    AuthStorage, ContentBlock, ContentValue, ModelRegistry, SessionEntry, SessionManager,
37    SessionTreeNode, Settings, ValidationReport,
38};
39
40/// Context for compaction operations, passed to extension hooks
41#[derive(Debug, Clone)]
42pub struct CompactionContext {
43    /// Messages being compacted
44    pub messages_count: usize,
45    /// Estimated tokens before compaction
46    pub tokens_before: usize,
47    /// Target token count after compaction
48    pub target_tokens: usize,
49    /// Strategy being used
50    pub strategy: String,
51}
52
53impl CompactionContext {
54    /// Create a new compaction context
55    pub fn new(
56        messages_count: usize,
57        tokens_before: usize,
58        target_tokens: usize,
59        strategy: impl Into<String>,
60    ) -> Self {
61        Self {
62            messages_count,
63            tokens_before,
64            target_tokens,
65            strategy: strategy.into(),
66        }
67    }
68
69    /// Get expected compression ratio
70    pub fn compression_ratio(&self) -> f32 {
71        if self.tokens_before == 0 {
72            return 1.0;
73        }
74        self.target_tokens as f32 / self.tokens_before as f32
75    }
76}
77
78// ─── Module-level imports ────────────────────────────────────────────────────
79use anyhow::{Error, Result};
80use oxi_agent::{Agent, AgentConfig, AgentEvent};
81use parking_lot::RwLock;
82use skills::SkillManager;
83use std::sync::Arc;
84use uuid::Uuid;
85
86// ─── Application state ───────────────────────────────────────────────────────
87
88/// Application state and entry point
89pub struct App {
90    agent: Arc<Agent>,
91    settings: Settings,
92    skills: RwLock<SkillManager>,
93    active_skills: RwLock<Vec<String>>,
94    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
95    questionnaire_bridge:
96        Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
97}
98
99/// Chat message for display
100#[derive(Debug, Clone, serde::Serialize)]
101pub struct ChatMessage {
102    /// Role of the message sender (e.g. "user" or "assistant").
103    pub role: String,
104    /// Text content of the message.
105    pub content: String,
106    /// Timestamp when the message was created.
107    pub timestamp: chrono::DateTime<chrono::Utc>,
108}
109
110impl ChatMessage {
111    /// Create a new user chat message.
112    pub fn user(content: String) -> Self {
113        Self {
114            role: "user".to_string(),
115            content,
116            timestamp: chrono::Utc::now(),
117        }
118    }
119
120    /// Create a new assistant chat message.
121    pub fn assistant(content: String) -> Self {
122        Self {
123            role: "assistant".to_string(),
124            content,
125            timestamp: chrono::Utc::now(),
126        }
127    }
128}
129
130/// Interactive session state
131#[derive(Debug, Clone, Default)]
132pub struct InteractiveSession {
133    /// Chat messages exchanged so far.
134    pub messages: Vec<ChatMessage>,
135    /// Whether the assistant is currently generating a response.
136    pub thinking: bool,
137    /// Partial response text accumulated during streaming.
138    pub current_response: String,
139    /// Unique session identifier.
140    pub session_id: Option<Uuid>,
141    /// Optional human-readable session name.
142    pub name: Option<String>,
143    /// Raw session entries for persistence and tree navigation.
144    pub entries: Vec<SessionEntry>,
145}
146
147impl InteractiveSession {
148    /// Create a new empty interactive session.
149    pub fn new() -> Self {
150        Self::default()
151    }
152
153    /// Add a user message to the session.
154    pub fn add_user_message(&mut self, content: String) {
155        self.messages.push(ChatMessage::user(content.clone()));
156        let entry = SessionEntry::new(AgentMessage::User {
157            content: ContentValue::String(content),
158        });
159        self.entries.push(entry);
160    }
161
162    /// Add an assistant message to the session.
163    pub fn add_assistant_message(&mut self, content: String) {
164        self.messages.push(ChatMessage::assistant(content.clone()));
165        let entry = SessionEntry::new(AgentMessage::Assistant {
166            content: vec![AssistantContentBlock::Text { text: content }],
167            provider: None,
168            model_id: None,
169            usage: None,
170            stop_reason: None,
171        });
172        self.entries.push(entry);
173        self.current_response.clear();
174    }
175
176    /// Append text to the current partial streaming response.
177    pub fn append_to_response(&mut self, text: &str) {
178        self.current_response.push_str(text);
179    }
180
181    /// Finalize the current streaming response into a full assistant message.
182    pub fn finish_response(&mut self) {
183        if !self.current_response.is_empty() {
184            let response = std::mem::take(&mut self.current_response);
185            self.add_assistant_message(response);
186        }
187    }
188
189    /// Get all entries in the session
190    pub fn entries(&self) -> &[SessionEntry] {
191        &self.entries
192    }
193
194    /// Get entry at a specific index
195    pub fn get_entry(&self, index: usize) -> Option<&SessionEntry> {
196        self.entries.get(index)
197    }
198
199    /// Get entry by ID
200    pub fn get_entry_by_id(&self, id: &str) -> Option<&SessionEntry> {
201        self.entries.iter().find(|e| e.id == id)
202    }
203
204    /// Truncate entries at a given index (for branching)
205    pub fn truncate_at(&mut self, index: usize) {
206        self.entries.truncate(index + 1);
207    }
208}
209
210// ─── System prompt builder ───────────────────────────────────────────────────
211
212fn build_system_prompt(
213    thinking_level: oxi_store::settings::ThinkingLevel,
214    skill_contents: &[String],
215) -> String {
216    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
217        .iter()
218        .enumerate()
219        .map(|(i, content)| prompt::system_prompt::Skill {
220            name: format!("skill-{}", i),
221            content: content.clone(),
222        })
223        .collect();
224
225    let options = prompt::system_prompt::BuildSystemPromptOptions {
226        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
227        skills,
228        cwd: std::env::current_dir()
229            .map(|p| p.to_string_lossy().to_string())
230            .unwrap_or_default(),
231        ..Default::default()
232    };
233
234    prompt::system_prompt::build_system_prompt(&options)
235}
236
237// ─── App implementation ─────────────────────────────────────────────────────
238
239impl App {
240    /// Create a new App instance
241    pub async fn new(settings: Settings) -> Result<Self> {
242        let model_id = settings.effective_model(None).unwrap_or_default();
243        let provider_name = settings
244            .effective_provider(None)
245            .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
246
247        let (provider_name, model_name) = if model_id.contains('/') {
248            let parts: Vec<&str> = model_id.split('/').collect();
249            (parts[0].to_string(), parts[1..].join("/"))
250        } else if !model_id.is_empty() {
251            (provider_name.clone(), model_id.clone())
252        } else {
253            (String::new(), String::new())
254        };
255
256        // Resolve model (validation only)
257        if !provider_name.is_empty() && !model_name.is_empty() {
258            let _ = oxi_ai::lookup_model(&provider_name, &model_name);
259        }
260
261        // Resolve provider via oxi-ai built-in factory
262        let provider: Arc<dyn oxi_ai::Provider> = {
263            let name = if provider_name.is_empty() {
264                "anthropic"
265            } else {
266                &provider_name
267            };
268            oxi_ai::get_provider_arc(name)
269                .ok_or_else(|| Error::msg(format!("Provider '{}' not found", name)))?
270        };
271
272        let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
273            dirs::home_dir()
274                .unwrap_or_default()
275                .join(".oxi")
276                .join("skills")
277        });
278        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
279            tracing::debug!("Skills not loaded: {}", e);
280            SkillManager::new()
281        });
282
283        let system_prompt = build_system_prompt(settings.thinking_level, &[]);
284        let compaction_strategy = if settings.auto_compaction {
285            oxi_ai::CompactionStrategy::Threshold(0.8)
286        } else {
287            oxi_ai::CompactionStrategy::Disabled
288        };
289        let auth = oxi_store::auth_storage::shared_auth_storage();
290        let api_key = auth.get_api_key(&provider_name);
291
292        let config = AgentConfig {
293            name: "oxi".to_string(),
294            description: Some("oxi CLI agent".to_string()),
295            model_id: model_id.clone(),
296            system_prompt: Some(system_prompt),
297            max_iterations: 10,
298            timeout_seconds: settings.tool_timeout_seconds,
299            temperature: settings.effective_temperature(),
300            max_tokens: settings.effective_max_tokens(),
301            compaction_strategy,
302            compaction_instruction: None,
303            context_window: 128_000,
304            api_key,
305            workspace_dir: Some(
306                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
307            ),
308            output_mode: None,
309            provider_options: None,
310        };
311
312        let agent = Arc::new(Agent::new(
313            provider,
314            config,
315            Arc::new(oxi_agent::ToolRegistry::new()),
316        ));
317
318        let bridge =
319            std::sync::Arc::new(oxi_agent::tools::questionnaire::QuestionnaireBridge::new());
320        let questionnaire_tool =
321            oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
322        agent
323            .tools()
324            .register_arc(std::sync::Arc::new(questionnaire_tool));
325
326        Ok(Self {
327            agent,
328            settings,
329            skills: RwLock::new(skills),
330            active_skills: RwLock::new(Vec::new()),
331            wasm_ext: None,
332            questionnaire_bridge: Some(bridge),
333        })
334    }
335
336
337    /// Get the current settings
338    pub fn settings(&self) -> &Settings {
339        &self.settings
340    }
341
342    /// Set the WASM extension manager
343    pub fn set_wasm_ext(
344        &mut self,
345        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
346    ) {
347        self.wasm_ext = ext;
348    }
349
350    /// Get the WASM extension manager
351    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
352        self.wasm_ext.as_ref()
353    }
354
355    /// Get a reference to the underlying agent.
356    pub fn agent(&self) -> Arc<Agent> {
357        Arc::clone(&self.agent)
358    }
359
360    /// Get the tool registry (for registering extension tools)
361    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
362        self.agent.tools()
363    }
364
365    /// Get the questionnaire bridge, if initialized.
366    pub fn questionnaire_bridge(
367        &self,
368    ) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
369        self.questionnaire_bridge.as_ref()
370    }
371
372    /// Get a reference to the skill manager
373    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
374        self.skills.read()
375    }
376
377    /// Activate a skill by name. Returns an error string if not found.
378    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
379        {
380            let skills = self.skills.read();
381            if skills.get(name).is_none() {
382                return Err(format!("Skill '{}' not found", name));
383            }
384        }
385        let name_lower = name.to_lowercase();
386        {
387            let mut active = self.active_skills.write();
388            if !active.contains(&name_lower) {
389                active.push(name_lower);
390            }
391        }
392        self.rebuild_system_prompt();
393        Ok(())
394    }
395
396    /// Deactivate a skill by name.
397    pub fn deactivate_skill(&self, name: &str) {
398        let name_lower = name.to_lowercase();
399        {
400            let mut active = self.active_skills.write();
401            active.retain(|n| n != &name_lower);
402        }
403        self.rebuild_system_prompt();
404    }
405
406    /// List currently active skill names
407    pub fn active_skills(&self) -> Vec<String> {
408        self.active_skills.read().clone()
409    }
410
411    /// Rebuild the system prompt with current active skills
412    fn rebuild_system_prompt(&self) {
413        let active = self.active_skills.read();
414        let skills = self.skills.read();
415        let contents: Vec<String> = active
416            .iter()
417            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
418            .collect();
419        let prompt = build_system_prompt(self.settings.thinking_level, &contents);
420        self.agent.set_system_prompt(prompt);
421    }
422
423    /// Get a clone of the current state
424    pub fn agent_state(&self) -> oxi_agent::AgentState {
425        self.agent.state()
426    }
427
428    /// Run a single prompt and return the response
429    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
430        let (response, _events) = self.agent.run(prompt).await?;
431        Ok(response.content)
432    }
433
434    /// Run a prompt with event callback
435    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
436    where
437        F: FnMut(AgentEvent) + Send + 'static,
438    {
439        self.agent.run_streaming(prompt, on_event).await?;
440        let state = self.agent_state();
441        for msg in state.messages.iter().rev() {
442            if let oxi_ai::Message::Assistant(a) = msg {
443                return Ok(a.text_content());
444            }
445        }
446        Ok(String::new())
447    }
448
449    /// Run in interactive mode, returning an event stream
450    pub async fn run_interactive(&self) -> Result<InteractiveLoop<'_>> {
451        let session = InteractiveSession::new();
452        Ok(InteractiveLoop { app: self, session })
453    }
454
455    /// Reset the conversation
456    pub fn reset(&self) {
457        self.agent.reset();
458    }
459
460    /// Switch the model used for future LLM calls.
461    pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
462        let parts: Vec<&str> = model_id.split('/').collect();
463        let provider = parts
464            .first()
465            .map(|s| s.to_string())
466            .unwrap_or_else(|| "anthropic".to_string());
467        let api_key = oxi_store::auth_storage::shared_auth_storage().get_api_key(&provider);
468        self.agent.switch_model(model_id, api_key)
469    }
470
471    /// Get the current model ID
472    pub fn model_id(&self) -> String {
473        self.agent.model_id()
474    }
475}
476
477/// Interactive loop handle
478pub struct InteractiveLoop<'a> {
479    app: &'a App,
480    session: InteractiveSession,
481}
482
483impl<'a> InteractiveLoop<'a> {
484    /// Add a user message and get the assistant response
485    pub async fn send_message(&mut self, prompt: String) -> Result<()> {
486        self.session.add_user_message(prompt.clone());
487        self.session.thinking = true;
488
489        let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
490        let agent = Arc::clone(&self.app.agent);
491
492        let local = tokio::task::LocalSet::new();
493        local.spawn_local(async move {
494            let _ = agent.run_with_channel(prompt, tx).await;
495        });
496
497        while let Ok(event) = rx.recv() {
498            match event {
499                AgentEvent::TextChunk { text } => {
500                    self.session.append_to_response(&text);
501                }
502                AgentEvent::Thinking => {}
503                AgentEvent::Complete { .. } => {
504                    self.session.finish_response();
505                    self.session.thinking = false;
506                }
507                AgentEvent::Error { message, .. } => {
508                    self.session
509                        .append_to_response(&format!("[Error: {}]", message));
510                    self.session.finish_response();
511                    self.session.thinking = false;
512                }
513                _ => {}
514            }
515        }
516
517        local.await;
518        Ok(())
519    }
520
521    /// Get current messages
522    pub fn messages(&self) -> &[ChatMessage] {
523        &self.session.messages
524    }
525
526    /// Get the current partial response (while thinking)
527    pub fn current_response(&self) -> &str {
528        &self.session.current_response
529    }
530
531    /// Check if currently thinking
532    pub fn is_thinking(&self) -> bool {
533        self.session.thinking
534    }
535
536    /// Get session entries for tree navigation
537    pub fn entries(&self) -> &[SessionEntry] {
538        self.session.entries()
539    }
540
541    /// Get entry by ID
542    pub fn get_entry(&self, id: Uuid) -> Option<&SessionEntry> {
543        self.session.get_entry_by_id(&id.to_string())
544    }
545
546    /// Switch the model used for future LLM calls
547    pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
548        self.app.switch_model(model_id)
549    }
550
551    /// Get the current model ID
552    pub fn model_id(&self) -> String {
553        self.app.model_id()
554    }
555}