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 bootstrap;
12pub mod cli;
13pub mod main_dispatch;
14pub mod print_mode;
15pub mod services;
16pub mod setup_wizard;
17pub mod store;
18
19// ─── Directory groups ───────────────────────────────────────────────────────
20pub(crate) mod app;
21pub(crate) mod context;
22pub mod extensions; // public for main.rs
23pub(crate) mod infra;
24pub(crate) mod media;
25pub(crate) mod prompt;
26pub(crate) mod rpc_mode;
27pub(crate) mod skills;
28pub mod storage; // public for main.rs (packages)
29                 // Re-exports from storage for main.rs
30pub use storage::packages::PackageManager;
31pub use storage::packages::ResourceKind;
32pub mod tui; // public for main.rs
33pub(crate) mod ui;
34pub(crate) mod util;
35
36///
37/// This is the **new entry point** for oxi-cli run modes. It uses
38/// `oxi-fs` adapters and `OxiBuilder::with_port_*` to construct an
39/// `Oxi` with persistence, auth, config, and skills wired. The legacy
40/// `App::new` path is still used by the interactive TUI during the
41/// migration period.
42///
43/// # Example
44///
45/// ```no_run
46/// use oxi::build_oxi_engine;
47/// # fn _example() -> anyhow::Result<()> {
48/// let oxi = build_oxi_engine()?;
49/// println!("providers: {}", oxi.providers().names().len());
50/// # Ok(()) }
51/// ```
52pub fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
53    let paths = services::OxiPaths::default_paths()?;
54    services::build_oxi(&paths)
55}
56
57/// Self-check the wired port implementations. Prints a one-line summary
58/// per port and returns `Ok(())` if all are reachable.
59///
60/// Triggered by the `OXI_PORT_CHECK=1` environment variable from
61/// `oxi-cli/src/main.rs`. Useful for verifying the new composition root
62/// without disturbing the legacy `App::new` path.
63pub async fn run_port_check() -> anyhow::Result<()> {
64    let oxi = build_oxi_engine()?;
65    let ports = oxi.ports();
66
67    // State
68    let entries = ports.state.list("").await?;
69    println!("[state]    entries: {}", entries.len());
70
71    // Auth
72    let providers = ports.auth.list_providers().await?;
73    println!("[auth]     providers with credentials: {:?}", providers);
74
75    // Config
76    let keys = ports.config.list()?;
77    println!("[config]   keys: {}", keys.len());
78
79    // Skills
80    let skills = ports.skills.list().await?;
81    println!("[skills]   {} skill(s) discovered", skills.len());
82    for s in &skills {
83        println!("           - {}: {}", s.name, s.description);
84    }
85
86    // Event bus / memory / etc — all noop unless registered
87    let _ = ports
88        .event_bus
89        .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
90        .await;
91    println!("[event-bus] publish ok (noop bus if not registered)");
92
93    println!("\nport check: ok");
94    Ok(())
95}
96
97/// Context for compaction operations, passed to extension hooks
98#[derive(Debug, Clone)]
99pub struct CompactionContext {
100    /// Messages being compacted
101    pub messages_count: usize,
102    /// Estimated tokens before compaction
103    pub tokens_before: usize,
104    /// Target token count after compaction
105    pub target_tokens: usize,
106    /// Strategy being used
107    pub strategy: String,
108}
109
110impl CompactionContext {
111    /// Create a new compaction context
112    pub fn new(
113        messages_count: usize,
114        tokens_before: usize,
115        target_tokens: usize,
116        strategy: impl Into<String>,
117    ) -> Self {
118        Self {
119            messages_count,
120            tokens_before,
121            target_tokens,
122            strategy: strategy.into(),
123        }
124    }
125
126    /// Get expected compression ratio
127    pub fn compression_ratio(&self) -> f32 {
128        if self.tokens_before == 0 {
129            return 1.0;
130        }
131        self.target_tokens as f32 / self.tokens_before as f32
132    }
133}
134
135// ─── Module-level imports ────────────────────────────────────────────────────
136use crate::store::settings::Settings;
137use anyhow::{Error, Result};
138use oxi_agent::{Agent, AgentConfig, AgentEvent};
139use parking_lot::RwLock;
140use skills::SkillManager;
141use std::sync::Arc;
142
143// ─── Application state ───────────────────────────────────────────────────────
144
145/// Application state and entry point.
146///
147/// Holds an `Oxi` engine (composition root) and a single `Agent` built
148/// from it. The legacy `App::new(settings)` constructor is **gone**;
149/// use [`App::from_oxi`] with a wired `Oxi` from
150/// [`build_oxi_engine`].
151pub struct App {
152    oxi: oxi_sdk::Oxi,
153    agent: Arc<Agent>,
154    settings: Settings,
155    skills: RwLock<SkillManager>,
156    active_skills: RwLock<Vec<String>>,
157    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
158    questionnaire_bridge:
159        Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
160}
161
162/// Context for compaction operations, passed to extension hooks
163// ─── System prompt builder ───────────────────────────────────────────────────
164fn build_system_prompt(
165    thinking_level: crate::store::settings::ThinkingLevel,
166    skill_contents: &[String],
167) -> String {
168    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
169        .iter()
170        .enumerate()
171        .map(|(i, content)| prompt::system_prompt::Skill {
172            name: format!("skill-{}", i),
173            content: content.clone(),
174        })
175        .collect();
176
177    let options = prompt::system_prompt::BuildSystemPromptOptions {
178        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
179        skills,
180        cwd: std::env::current_dir()
181            .map(|p| p.to_string_lossy().to_string())
182            .unwrap_or_default(),
183        ..Default::default()
184    };
185
186    prompt::system_prompt::build_system_prompt(&options)
187}
188
189// ─── App implementation ─────────────────────────────────────────────────────
190
191impl App {
192    /// Build an `App` from a wired `Oxi` engine and a settings object.
193    ///
194    /// The `Oxi` should be created via [`build_oxi_engine`] (or
195    /// `services::build_oxi`) so that all 11 ports are wired. The
196    /// settings hold the user's runtime configuration (model, thinking
197    /// level, etc.).
198    pub async fn from_oxi(oxi: oxi_sdk::Oxi, settings: Settings) -> Result<Self> {
199        let model_id = settings.effective_model(None).unwrap_or_default();
200        let provider_name = settings
201            .effective_provider(None)
202            .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
203
204        // Pull the API key from the wired port, not from oxi_store.
205        let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
206
207        let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
208            dirs::home_dir()
209                .unwrap_or_default()
210                .join(".oxi")
211                .join("skills")
212        });
213        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
214            tracing::debug!("Skills not loaded: {}", e);
215            SkillManager::new()
216        });
217
218        let system_prompt = build_system_prompt(settings.thinking_level, &[]);
219        let compaction_strategy = if settings.auto_compaction {
220            oxi_sdk::CompactionStrategy::Threshold(0.8)
221        } else {
222            oxi_sdk::CompactionStrategy::Disabled
223        };
224
225        let config = AgentConfig {
226            name: "oxi".to_string(),
227            description: Some("oxi CLI agent".to_string()),
228            model_id: model_id.clone(),
229            system_prompt: Some(system_prompt),
230            max_iterations: 10,
231            timeout_seconds: settings.tool_timeout_seconds,
232            temperature: settings.effective_temperature(),
233            max_tokens: settings.effective_max_tokens(),
234            compaction_strategy,
235            compaction_instruction: None,
236            context_window: 128_000,
237            api_key,
238            workspace_dir: Some(
239                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
240            ),
241            output_mode: None,
242            provider_options: None,
243        };
244
245        // Build the agent via the SDK's AgentBuilder — no manual wiring.
246        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
247        let agent = oxi
248            .agent(config)
249            .workspace(cwd)
250            .build()
251            .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
252        let agent = Arc::new(agent);
253
254        let bridge =
255            std::sync::Arc::new(oxi_agent::tools::questionnaire::QuestionnaireBridge::new());
256        let questionnaire_tool =
257            oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
258        agent
259            .tools()
260            .register_arc(std::sync::Arc::new(questionnaire_tool));
261
262        Ok(Self {
263            oxi,
264            agent,
265            settings,
266            skills: RwLock::new(skills),
267            active_skills: RwLock::new(Vec::new()),
268            wasm_ext: None,
269            questionnaire_bridge: Some(bridge),
270        })
271    }
272
273    /// Get the current settings
274    pub fn settings(&self) -> &Settings {
275        &self.settings
276    }
277
278    /// Set the WASM extension manager
279    pub fn set_wasm_ext(
280        &mut self,
281        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
282    ) {
283        self.wasm_ext = ext;
284    }
285
286    /// Get the WASM extension manager
287    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
288        self.wasm_ext.as_ref()
289    }
290
291    /// Get a reference to the underlying agent.
292    pub fn agent(&self) -> Arc<Agent> {
293        Arc::clone(&self.agent)
294    }
295
296    /// Get the tool registry (for registering extension tools)
297    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
298        self.agent.tools()
299    }
300
301    /// Get the questionnaire bridge, if initialized.
302    pub fn questionnaire_bridge(
303        &self,
304    ) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
305        self.questionnaire_bridge.as_ref()
306    }
307
308    /// Get a reference to the skill manager
309    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
310        self.skills.read()
311    }
312
313    /// Activate a skill by name. Returns an error string if not found.
314    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
315        {
316            let skills = self.skills.read();
317            if skills.get(name).is_none() {
318                return Err(format!("Skill '{}' not found", name));
319            }
320        }
321        let name_lower = name.to_lowercase();
322        {
323            let mut active = self.active_skills.write();
324            if !active.contains(&name_lower) {
325                active.push(name_lower);
326            }
327        }
328        self.rebuild_system_prompt();
329        Ok(())
330    }
331
332    /// Deactivate a skill by name.
333    pub fn deactivate_skill(&self, name: &str) {
334        let name_lower = name.to_lowercase();
335        {
336            let mut active = self.active_skills.write();
337            active.retain(|n| n != &name_lower);
338        }
339        self.rebuild_system_prompt();
340    }
341
342    /// List currently active skill names
343    pub fn active_skills(&self) -> Vec<String> {
344        self.active_skills.read().clone()
345    }
346
347    /// Rebuild the system prompt with current active skills
348    fn rebuild_system_prompt(&self) {
349        let active = self.active_skills.read();
350        let skills = self.skills.read();
351        let contents: Vec<String> = active
352            .iter()
353            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
354            .collect();
355        let prompt = build_system_prompt(self.settings.thinking_level, &contents);
356        self.agent.set_system_prompt(prompt);
357    }
358
359    /// Get a clone of the current state
360    pub fn agent_state(&self) -> oxi_agent::AgentState {
361        self.agent.state()
362    }
363
364    /// Run a single prompt and return the response
365    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
366        let (response, _events) = self.agent.run(prompt).await?;
367        Ok(response.content)
368    }
369
370    /// Run a prompt with event callback
371    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
372    where
373        F: FnMut(AgentEvent) + Send + 'static,
374    {
375        self.agent.run_streaming(prompt, on_event).await?;
376        let state = self.agent_state();
377        for msg in state.messages.iter().rev() {
378            if let oxi_sdk::Message::Assistant(a) = msg {
379                return Ok(a.text_content());
380            }
381        }
382        Ok(String::new())
383    }
384
385    /// Reset the conversation
386    pub fn reset(&self) {
387        self.agent.reset();
388    }
389
390    /// Switch the model used for future LLM calls.
391    pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
392        let parts: Vec<&str> = model_id.split('/').collect();
393        let provider = parts
394            .first()
395            .map(|s| s.to_string())
396            .unwrap_or_else(|| "anthropic".to_string());
397        let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
398        let _ = self.agent.switch_model(model_id, api_key);
399        Ok(())
400    }
401
402    /// Get the current model ID
403    pub fn model_id(&self) -> String {
404        self.agent.model_id()
405    }
406}