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 parking_lot::RwLock;
82use skills::SkillManager;
83use std::sync::Arc;
84use uuid::Uuid;
85
86pub 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#[derive(Debug, Clone, serde::Serialize)]
101pub struct ChatMessage {
102 pub role: String,
104 pub content: String,
106 pub timestamp: chrono::DateTime<chrono::Utc>,
108}
109
110impl ChatMessage {
111 pub fn user(content: String) -> Self {
113 Self {
114 role: "user".to_string(),
115 content,
116 timestamp: chrono::Utc::now(),
117 }
118 }
119
120 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#[derive(Debug, Clone, Default)]
132pub struct InteractiveSession {
133 pub messages: Vec<ChatMessage>,
135 pub thinking: bool,
137 pub current_response: String,
139 pub session_id: Option<Uuid>,
141 pub name: Option<String>,
143 pub entries: Vec<SessionEntry>,
145}
146
147impl InteractiveSession {
148 pub fn new() -> Self {
150 Self::default()
151 }
152
153 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 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 pub fn append_to_response(&mut self, text: &str) {
178 self.current_response.push_str(text);
179 }
180
181 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 pub fn entries(&self) -> &[SessionEntry] {
191 &self.entries
192 }
193
194 pub fn get_entry(&self, index: usize) -> Option<&SessionEntry> {
196 self.entries.get(index)
197 }
198
199 pub fn get_entry_by_id(&self, id: &str) -> Option<&SessionEntry> {
201 self.entries.iter().find(|e| e.id == id)
202 }
203
204 pub fn truncate_at(&mut self, index: usize) {
206 self.entries.truncate(index + 1);
207 }
208}
209
210fn 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
237impl App {
240 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 if !provider_name.is_empty() && !model_name.is_empty() {
258 let _ = oxi_ai::lookup_model(&provider_name, &model_name);
259 }
260
261 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 pub fn settings(&self) -> &Settings {
339 &self.settings
340 }
341
342 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 pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
352 self.wasm_ext.as_ref()
353 }
354
355 pub fn agent(&self) -> Arc<Agent> {
357 Arc::clone(&self.agent)
358 }
359
360 pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
362 self.agent.tools()
363 }
364
365 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 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
374 self.skills.read()
375 }
376
377 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 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 pub fn active_skills(&self) -> Vec<String> {
408 self.active_skills.read().clone()
409 }
410
411 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 pub fn agent_state(&self) -> oxi_agent::AgentState {
425 self.agent.state()
426 }
427
428 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 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 pub async fn run_interactive(&self) -> Result<InteractiveLoop<'_>> {
451 let session = InteractiveSession::new();
452 Ok(InteractiveLoop { app: self, session })
453 }
454
455 pub fn reset(&self) {
457 self.agent.reset();
458 }
459
460 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 pub fn model_id(&self) -> String {
473 self.agent.model_id()
474 }
475}
476
477pub struct InteractiveLoop<'a> {
479 app: &'a App,
480 session: InteractiveSession,
481}
482
483impl<'a> InteractiveLoop<'a> {
484 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 pub fn messages(&self) -> &[ChatMessage] {
523 &self.session.messages
524 }
525
526 pub fn current_response(&self) -> &str {
528 &self.session.current_response
529 }
530
531 pub fn is_thinking(&self) -> bool {
533 self.session.thinking
534 }
535
536 pub fn entries(&self) -> &[SessionEntry] {
538 self.session.entries()
539 }
540
541 pub fn get_entry(&self, id: Uuid) -> Option<&SessionEntry> {
543 self.session.get_entry_by_id(&id.to_string())
544 }
545
546 pub fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
548 self.app.switch_model(model_id)
549 }
550
551 pub fn model_id(&self) -> String {
553 self.app.model_id()
554 }
555}