1#![warn(missing_docs)]
2#![warn(clippy::unwrap_used)]
3#![allow(unknown_lints)]
4
5pub mod cli;
12pub mod print_mode;
13pub mod setup_wizard;
14
15pub(crate) mod app;
17pub(crate) mod context;
18pub mod extensions; pub(crate) mod infra;
20pub(crate) mod media;
21pub(crate) mod prompt;
22pub(crate) mod rpc_mode;
23pub(crate) mod skills;
24pub mod storage; pub use storage::packages::PackageManager;
27pub use storage::packages::ResourceKind;
28pub mod tui; pub(crate) mod ui;
30pub(crate) mod util;
31
32pub 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#[derive(Debug, Clone)]
42pub struct CompactionContext {
43 pub messages_count: usize,
45 pub tokens_before: usize,
47 pub target_tokens: usize,
49 pub strategy: String,
51}
52
53impl CompactionContext {
54 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 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
78use anyhow::{Error, Result};
80use oxi_agent::{Agent, AgentConfig, AgentEvent};
81use oxi_sdk::OxiBuilder;
82use parking_lot::RwLock;
83use skills::SkillManager;
84use std::sync::Arc;
85use uuid::Uuid;
86
87pub struct App {
91 #[allow(dead_code)]
93 engine: oxi_sdk::Oxi,
94 agent: Arc<Agent>,
95 settings: Settings,
96 skills: RwLock<SkillManager>,
97 active_skills: RwLock<Vec<String>>,
98 wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
99 questionnaire_bridge:
100 Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
101}
102
103#[derive(Debug, Clone, serde::Serialize)]
105pub struct ChatMessage {
106 pub role: String,
108 pub content: String,
110 pub timestamp: chrono::DateTime<chrono::Utc>,
112}
113
114impl ChatMessage {
115 pub fn user(content: String) -> Self {
117 Self {
118 role: "user".to_string(),
119 content,
120 timestamp: chrono::Utc::now(),
121 }
122 }
123
124 pub fn assistant(content: String) -> Self {
126 Self {
127 role: "assistant".to_string(),
128 content,
129 timestamp: chrono::Utc::now(),
130 }
131 }
132}
133
134#[derive(Debug, Clone, Default)]
136pub struct InteractiveSession {
137 pub messages: Vec<ChatMessage>,
139 pub thinking: bool,
141 pub current_response: String,
143 pub session_id: Option<Uuid>,
145 pub name: Option<String>,
147 pub entries: Vec<SessionEntry>,
149}
150
151impl InteractiveSession {
152 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub fn add_user_message(&mut self, content: String) {
159 self.messages.push(ChatMessage::user(content.clone()));
160 let entry = SessionEntry::new(AgentMessage::User {
161 content: ContentValue::String(content),
162 });
163 self.entries.push(entry);
164 }
165
166 pub fn add_assistant_message(&mut self, content: String) {
168 self.messages.push(ChatMessage::assistant(content.clone()));
169 let entry = SessionEntry::new(AgentMessage::Assistant {
170 content: vec![AssistantContentBlock::Text { text: content }],
171 provider: None,
172 model_id: None,
173 usage: None,
174 stop_reason: None,
175 });
176 self.entries.push(entry);
177 self.current_response.clear();
178 }
179
180 pub fn append_to_response(&mut self, text: &str) {
182 self.current_response.push_str(text);
183 }
184
185 pub fn finish_response(&mut self) {
187 if !self.current_response.is_empty() {
188 let response = std::mem::take(&mut self.current_response);
189 self.add_assistant_message(response);
190 }
191 }
192
193 pub fn entries(&self) -> &[SessionEntry] {
195 &self.entries
196 }
197
198 pub fn get_entry(&self, index: usize) -> Option<&SessionEntry> {
200 self.entries.get(index)
201 }
202
203 pub fn get_entry_by_id(&self, id: &str) -> Option<&SessionEntry> {
205 self.entries.iter().find(|e| e.id == id)
206 }
207
208 pub fn truncate_at(&mut self, index: usize) {
210 self.entries.truncate(index + 1);
211 }
212}
213
214fn build_system_prompt(
217 thinking_level: oxi_store::settings::ThinkingLevel,
218 skill_contents: &[String],
219) -> String {
220 let skills: Vec<prompt::system_prompt::Skill> = skill_contents
221 .iter()
222 .enumerate()
223 .map(|(i, content)| prompt::system_prompt::Skill {
224 name: format!("skill-{}", i),
225 content: content.clone(),
226 })
227 .collect();
228
229 let options = prompt::system_prompt::BuildSystemPromptOptions {
230 custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
231 skills,
232 cwd: std::env::current_dir()
233 .map(|p| p.to_string_lossy().to_string())
234 .unwrap_or_default(),
235 ..Default::default()
236 };
237
238 prompt::system_prompt::build_system_prompt(&options)
239}
240
241impl App {
244 pub async fn new(settings: Settings) -> Result<Self> {
246 let model_id = settings.effective_model(None).unwrap_or_default();
247 let provider_name = settings
248 .effective_provider(None)
249 .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
250
251 let (provider_name, model_name) = if model_id.contains('/') {
252 let parts: Vec<&str> = model_id.split('/').collect();
253 (parts[0].to_string(), parts[1..].join("/"))
254 } else if !model_id.is_empty() {
255 (provider_name.clone(), model_id.clone())
256 } else {
257 (String::new(), String::new())
258 };
259
260 let engine = OxiBuilder::new().with_builtins().build();
262
263 if !provider_name.is_empty() && !model_name.is_empty() {
265 let _ = engine.resolve_model(&format!("{}/{}", provider_name, model_name));
266 }
267
268 let provider: Arc<dyn oxi_ai::Provider> = if !provider_name.is_empty() {
270 engine
271 .create_provider(&provider_name)
272 .map_err(|e| Error::msg(format!("{}", e)))?
273 } else {
274 engine
275 .create_provider("anthropic")
276 .map_err(|e| Error::msg(format!("{}", e)))?
277 };
278
279 let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
280 dirs::home_dir()
281 .unwrap_or_default()
282 .join(".oxi")
283 .join("skills")
284 });
285 let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
286 tracing::debug!("Skills not loaded: {}", e);
287 SkillManager::new()
288 });
289
290 let system_prompt = build_system_prompt(settings.thinking_level, &[]);
291 let compaction_strategy = if settings.auto_compaction {
292 oxi_ai::CompactionStrategy::Threshold(0.8)
293 } else {
294 oxi_ai::CompactionStrategy::Disabled
295 };
296 let auth = oxi_store::auth_storage::shared_auth_storage();
297 let api_key = auth.get_api_key(&provider_name);
298
299 let config = AgentConfig {
300 name: "oxi".to_string(),
301 description: Some("oxi CLI agent".to_string()),
302 model_id: model_id.clone(),
303 system_prompt: Some(system_prompt),
304 max_iterations: 10,
305 timeout_seconds: settings.tool_timeout_seconds,
306 temperature: settings.effective_temperature(),
307 max_tokens: settings.effective_max_tokens(),
308 compaction_strategy,
309 compaction_instruction: None,
310 context_window: 128_000,
311 api_key,
312 workspace_dir: Some(
313 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
314 ),
315 output_mode: None,
316 provider_options: None,
317 };
318
319 let agent = Arc::new(Agent::new(
320 provider,
321 config,
322 Arc::new(oxi_agent::ToolRegistry::new()),
323 ));
324
325 let bridge =
326 std::sync::Arc::new(oxi_agent::tools::questionnaire::QuestionnaireBridge::new());
327 let questionnaire_tool =
328 oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
329 agent
330 .tools()
331 .register_arc(std::sync::Arc::new(questionnaire_tool));
332
333 Ok(Self {
334 engine,
335 agent,
336 settings,
337 skills: RwLock::new(skills),
338 active_skills: RwLock::new(Vec::new()),
339 wasm_ext: None,
340 questionnaire_bridge: Some(bridge),
341 })
342 }
343
344 #[allow(dead_code)]
346 pub(crate) fn engine(&self) -> &oxi_sdk::Oxi {
347 &self.engine
348 }
349
350 pub fn settings(&self) -> &Settings {
352 &self.settings
353 }
354
355 pub fn set_wasm_ext(
357 &mut self,
358 ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
359 ) {
360 self.wasm_ext = ext;
361 }
362
363 pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
365 self.wasm_ext.as_ref()
366 }
367
368 pub fn agent(&self) -> Arc<Agent> {
370 Arc::clone(&self.agent)
371 }
372
373 pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
375 self.agent.tools()
376 }
377
378 pub fn questionnaire_bridge(
380 &self,
381 ) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
382 self.questionnaire_bridge.as_ref()
383 }
384
385 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
387 self.skills.read()
388 }
389
390 pub fn activate_skill(&self, name: &str) -> Result<(), String> {
392 {
393 let skills = self.skills.read();
394 if skills.get(name).is_none() {
395 return Err(format!("Skill '{}' not found", name));
396 }
397 }
398 let name_lower = name.to_lowercase();
399 {
400 let mut active = self.active_skills.write();
401 if !active.contains(&name_lower) {
402 active.push(name_lower);
403 }
404 }
405 self.rebuild_system_prompt();
406 Ok(())
407 }
408
409 pub fn deactivate_skill(&self, name: &str) {
411 let name_lower = name.to_lowercase();
412 {
413 let mut active = self.active_skills.write();
414 active.retain(|n| n != &name_lower);
415 }
416 self.rebuild_system_prompt();
417 }
418
419 pub fn active_skills(&self) -> Vec<String> {
421 self.active_skills.read().clone()
422 }
423
424 fn rebuild_system_prompt(&self) {
426 let active = self.active_skills.read();
427 let skills = self.skills.read();
428 let contents: Vec<String> = active
429 .iter()
430 .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
431 .collect();
432 let prompt = build_system_prompt(self.settings.thinking_level, &contents);
433 self.agent.set_system_prompt(prompt);
434 }
435
436 pub fn agent_state(&self) -> oxi_agent::AgentState {
438 self.agent.state()
439 }
440
441 pub async fn run_prompt(&self, prompt: String) -> Result<String> {
443 let (response, _events) = self.agent.run(prompt).await?;
444 Ok(response.content)
445 }
446
447 pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
449 where
450 F: FnMut(AgentEvent) + Send + 'static,
451 {
452 self.agent.run_streaming(prompt, on_event).await?;
453 let state = self.agent_state();
454 for msg in state.messages.iter().rev() {
455 if let oxi_ai::Message::Assistant(a) = msg {
456 return Ok(a.text_content());
457 }
458 }
459 Ok(String::new())
460 }
461
462 pub async fn run_interactive(&self) -> Result<InteractiveLoop<'_>> {
464 let session = InteractiveSession::new();
465 Ok(InteractiveLoop { app: self, session })
466 }
467
468 pub fn reset(&self) {
470 self.agent.reset();
471 }
472
473 pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
475 let parts: Vec<&str> = model_id.split('/').collect();
476 let provider = parts
477 .first()
478 .map(|s| s.to_string())
479 .unwrap_or_else(|| "anthropic".to_string());
480 let api_key = oxi_store::auth_storage::shared_auth_storage().get_api_key(&provider);
481 self.agent.switch_model(model_id, api_key)
482 }
483
484 pub fn model_id(&self) -> String {
486 self.agent.model_id()
487 }
488}
489
490pub struct InteractiveLoop<'a> {
492 app: &'a App,
493 session: InteractiveSession,
494}
495
496impl<'a> InteractiveLoop<'a> {
497 pub async fn send_message(&mut self, prompt: String) -> Result<()> {
499 self.session.add_user_message(prompt.clone());
500 self.session.thinking = true;
501
502 let (tx, rx) = std::sync::mpsc::channel::<AgentEvent>();
503 let agent = Arc::clone(&self.app.agent);
504
505 let local = tokio::task::LocalSet::new();
506 local.spawn_local(async move {
507 let _ = agent.run_with_channel(prompt, tx).await;
508 });
509
510 while let Ok(event) = rx.recv() {
511 match event {
512 AgentEvent::TextChunk { text } => {
513 self.session.append_to_response(&text);
514 }
515 AgentEvent::Thinking => {}
516 AgentEvent::Complete { .. } => {
517 self.session.finish_response();
518 self.session.thinking = false;
519 }
520 AgentEvent::Error { message, .. } => {
521 self.session
522 .append_to_response(&format!("[Error: {}]", message));
523 self.session.finish_response();
524 self.session.thinking = false;
525 }
526 _ => {}
527 }
528 }
529
530 local.await;
531 Ok(())
532 }
533
534 pub fn messages(&self) -> &[ChatMessage] {
536 &self.session.messages
537 }
538
539 pub fn current_response(&self) -> &str {
541 &self.session.current_response
542 }
543
544 pub fn is_thinking(&self) -> bool {
546 self.session.thinking
547 }
548
549 pub fn entries(&self) -> &[SessionEntry] {
551 self.session.entries()
552 }
553
554 pub fn get_entry(&self, id: Uuid) -> Option<&SessionEntry> {
556 self.session.get_entry_by_id(&id.to_string())
557 }
558
559 pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
561 self.app.switch_model(model_id)
562 }
563
564 pub fn model_id(&self) -> String {
566 self.app.model_id()
567 }
568}