1#![warn(missing_docs)]
2#![warn(clippy::unwrap_used)]
9#![cfg_attr(test, allow(clippy::unwrap_used, clippy::field_reassign_with_default))]
10#![allow(unknown_lints)]
11
12pub mod bootstrap;
19pub mod cli;
20pub mod main_dispatch;
21pub mod print_mode;
22pub mod services;
23pub mod setup_wizard;
24pub mod store;
25
26pub(crate) mod app;
28pub(crate) mod context;
29pub mod extensions; pub(crate) mod infra;
31pub(crate) mod media;
32pub(crate) mod prompt;
33pub(crate) mod rpc_mode;
34pub(crate) mod skills;
35pub mod storage; pub use storage::packages::PackageManager;
38pub use storage::packages::ResourceKind;
39pub mod tools;
40pub mod tui; pub(crate) mod ui;
42pub(crate) mod util;
43
44pub async fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
61 let paths = services::OxiPaths::default_paths()?;
62 services::build_oxi(&paths).await
63}
64
65pub async fn run_port_check() -> anyhow::Result<()> {
72 let oxi = build_oxi_engine().await?;
73 let ports = oxi.ports();
74
75 let entries = ports.state.list("").await?;
77 println!("[state] entries: {}", entries.len());
78
79 let providers = ports.auth.list_providers().await?;
81 println!("[auth] providers with credentials: {:?}", providers);
82
83 let keys = ports.config.list()?;
85 println!("[config] keys: {}", keys.len());
86
87 let skills = ports.skills.list().await?;
89 println!("[skills] {} skill(s) discovered", skills.len());
90 for s in &skills {
91 println!(" - {}: {}", s.name, s.description);
92 }
93
94 let _ = ports
96 .event_bus
97 .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
98 .await;
99 println!("[event-bus] publish ok (noop bus if not registered)");
100
101 println!("\nport check: ok");
102 Ok(())
103}
104
105#[derive(Debug, Clone)]
107pub struct CompactionContext {
108 pub messages_count: usize,
110 pub tokens_before: usize,
112 pub target_tokens: usize,
114 pub strategy: String,
116}
117
118impl CompactionContext {
119 pub fn new(
121 messages_count: usize,
122 tokens_before: usize,
123 target_tokens: usize,
124 strategy: impl Into<String>,
125 ) -> Self {
126 Self {
127 messages_count,
128 tokens_before,
129 target_tokens,
130 strategy: strategy.into(),
131 }
132 }
133
134 pub fn compression_ratio(&self) -> f32 {
136 if self.tokens_before == 0 {
137 return 1.0;
138 }
139 self.target_tokens as f32 / self.tokens_before as f32
140 }
141}
142
143use crate::store::settings::Settings;
145use anyhow::{Error, Result};
146use oxi_agent::{Agent, AgentConfig, AgentEvent};
147use parking_lot::RwLock;
148use skills::SkillManager;
149use std::sync::Arc;
150
151pub struct App {
160 oxi: oxi_sdk::Oxi,
161 agent: Arc<Agent>,
162 settings: Settings,
163 skills: RwLock<SkillManager>,
164 active_skills: RwLock<Vec<String>>,
165 wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
166 questionnaire_bridge:
167 Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
168 issue_store: Option<crate::store::issues::FileIssueStore>,
172 ownership_session_id: String,
177 #[allow(dead_code)]
182 liveness_guard: Option<crate::store::issues::liveness::AliveGuard>,
183}
184
185fn build_system_prompt(
188 thinking_level: crate::store::settings::ThinkingLevel,
189 skill_contents: &[String],
190) -> String {
191 let skills: Vec<prompt::system_prompt::Skill> = skill_contents
192 .iter()
193 .enumerate()
194 .map(|(i, content)| prompt::system_prompt::Skill {
195 name: format!("skill-{}", i),
196 content: content.clone(),
197 })
198 .collect();
199
200 let options = prompt::system_prompt::BuildSystemPromptOptions {
201 custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
202 skills,
203 cwd: std::env::current_dir()
204 .map(|p| p.to_string_lossy().to_string())
205 .unwrap_or_default(),
206 ..Default::default()
207 };
208
209 prompt::system_prompt::build_system_prompt(&options)
210}
211
212impl App {
215 pub async fn from_oxi(
231 oxi: oxi_sdk::Oxi,
232 settings: Settings,
233 ownership_session_id: String,
234 ) -> Result<Self> {
235 let model_id = settings.effective_model(None).unwrap_or_default();
236 let provider_name = settings
237 .effective_provider(None)
238 .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
239
240 let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
242
243 let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
244 dirs::home_dir()
245 .unwrap_or_default()
246 .join(".oxi")
247 .join("skills")
248 });
249 let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
250 tracing::debug!("Skills not loaded: {}", e);
251 SkillManager::new()
252 });
253
254 let system_prompt = build_system_prompt(settings.thinking_level, &[]);
255 let compaction_strategy = if settings.auto_compaction {
256 oxi_sdk::CompactionStrategy::Threshold(0.8)
257 } else {
258 oxi_sdk::CompactionStrategy::Disabled
259 };
260
261 let config = AgentConfig {
262 name: "oxi".to_string(),
263 description: Some("oxi CLI agent".to_string()),
264 model_id: model_id.clone(),
265 system_prompt: Some(system_prompt),
266 timeout_seconds: settings.tool_timeout_seconds,
267 temperature: settings.effective_temperature(),
268 max_tokens: settings.effective_max_tokens(),
269 compaction_strategy,
270 compaction_instruction: None,
271 context_window: 128_000,
272 api_key,
273 workspace_dir: Some(
274 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
275 ),
276 output_mode: None,
277 provider_options: None,
278 session_id: Some(ownership_session_id.clone()),
279 };
280
281 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
283 let agent = oxi
284 .agent(config)
285 .workspace(cwd)
286 .build()
287 .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
288 let agent = Arc::new(agent);
289
290 let bridge =
291 std::sync::Arc::new(oxi_agent::tools::questionnaire::QuestionnaireBridge::new());
292 let questionnaire_tool =
293 oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
294 agent
295 .tools()
296 .register_arc(std::sync::Arc::new(questionnaire_tool));
297
298 let issue_store = std::env::current_dir()
303 .ok()
304 .map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
305 .and_then(|r| {
306 r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
307 .ok()
308 });
309
310 if let Some(store) = issue_store.clone() {
312 let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
313 agent.tools().register_arc(tool);
314 }
315
316 Ok(Self {
317 oxi,
318 agent,
319 settings,
320 skills: RwLock::new(skills),
321 active_skills: RwLock::new(Vec::new()),
322 wasm_ext: None,
323 questionnaire_bridge: Some(bridge),
324 issue_store,
325 ownership_session_id,
326 liveness_guard: None, })
328 .map(|mut app| {
329 app.liveness_guard =
333 acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
334 app
335 })
336 }
337
338 pub fn ownership_session_id(&self) -> &str {
341 &self.ownership_session_id
342 }
343
344 pub fn has_liveness_lock(&self) -> bool {
349 self.liveness_guard.is_some()
350 }
351
352 pub fn settings(&self) -> &Settings {
354 &self.settings
355 }
356
357 pub fn set_wasm_ext(
359 &mut self,
360 ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
361 ) {
362 self.wasm_ext = ext;
363 }
364
365 pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
367 self.wasm_ext.as_ref()
368 }
369
370 pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
372 self.issue_store.clone()
373 }
374
375 pub fn oxi(&self) -> &oxi_sdk::Oxi {
378 &self.oxi
379 }
380
381 pub fn agent(&self) -> Arc<Agent> {
383 Arc::clone(&self.agent)
384 }
385
386 pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
388 self.agent.tools()
389 }
390
391 pub fn questionnaire_bridge(
393 &self,
394 ) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
395 self.questionnaire_bridge.as_ref()
396 }
397
398 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
400 self.skills.read()
401 }
402
403 pub fn activate_skill(&self, name: &str) -> Result<(), String> {
405 {
406 let skills = self.skills.read();
407 if skills.get(name).is_none() {
408 return Err(format!("Skill '{}' not found", name));
409 }
410 }
411 let name_lower = name.to_lowercase();
412 {
413 let mut active = self.active_skills.write();
414 if !active.contains(&name_lower) {
415 active.push(name_lower);
416 }
417 }
418 self.rebuild_system_prompt();
419 Ok(())
420 }
421
422 pub fn deactivate_skill(&self, name: &str) {
424 let name_lower = name.to_lowercase();
425 {
426 let mut active = self.active_skills.write();
427 active.retain(|n| n != &name_lower);
428 }
429 self.rebuild_system_prompt();
430 }
431
432 pub fn active_skills(&self) -> Vec<String> {
434 self.active_skills.read().clone()
435 }
436
437 fn rebuild_system_prompt(&self) {
439 let active = self.active_skills.read();
440 let skills = self.skills.read();
441 let contents: Vec<String> = active
442 .iter()
443 .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
444 .collect();
445 let prompt = build_system_prompt(self.settings.thinking_level, &contents);
446 self.agent.set_system_prompt(prompt);
447 }
448
449 pub fn agent_state(&self) -> oxi_agent::AgentState {
451 self.agent.state()
452 }
453
454 pub async fn run_prompt(&self, prompt: String) -> Result<String> {
456 let (response, _events) = self.agent.run(prompt).await?;
457 Ok(response.content)
458 }
459
460 pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
462 where
463 F: FnMut(AgentEvent) + Send + 'static,
464 {
465 self.agent.run_streaming(prompt, on_event).await?;
466 let state = self.agent_state();
467 for msg in state.messages.iter().rev() {
468 if let oxi_sdk::Message::Assistant(a) = msg {
469 return Ok(a.text_content());
470 }
471 }
472 Ok(String::new())
473 }
474
475 pub fn reset(&self) {
477 self.agent.reset();
478 }
479
480 pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
482 let parts: Vec<&str> = model_id.split('/').collect();
483 let provider = parts
484 .first()
485 .map(|s| s.to_string())
486 .unwrap_or_else(|| "anthropic".to_string());
487 let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
488 let _ = self.agent.switch_model(model_id, api_key);
489 Ok(())
490 }
491
492 pub fn model_id(&self) -> String {
494 self.agent.model_id()
495 }
496}
497
498pub(crate) fn acquire_ownership_guard(
509 issue_store: Option<&crate::store::issues::FileIssueStore>,
510 ownership_id: &str,
511) -> Option<crate::store::issues::liveness::AliveGuard> {
512 let store = issue_store?;
513 if ownership_id.is_empty() {
514 return None;
517 }
518 crate::store::issues::liveness::acquire(&store.issues_dir(), ownership_id).ok()
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
528 use crate::store::issues::FileIssueStore;
529 use crate::store::issues::liveness;
530
531 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
532 let tmp = tempfile::tempdir().unwrap();
533 let dir = tmp.path().join(".oxi").join("issues");
534 std::fs::create_dir_all(&dir).unwrap();
535 (tmp, FileIssueStore::open(dir).unwrap())
536 }
537
538 #[test]
539 fn app_holds_single_liveness_lock() {
540 let (_tmp, store) = tmp_store();
544 let dir = store.issues_dir();
545 let id = "proc-test-app";
546
547 let guard = acquire_ownership_guard(Some(&store), id);
548 assert!(
549 guard.is_some(),
550 "App must acquire the liveness lock for its ownership id"
551 );
552 assert!(
553 liveness::is_session_alive(&dir, id),
554 "after acquire, the session must be live"
555 );
556
557 let second = liveness::acquire(&dir, id);
559 assert!(second.is_err(), "second acquire under same id must fail");
560
561 drop(guard);
562 assert!(
563 !liveness::is_session_alive(&dir, id),
564 "dropping App's guard releases the lock"
565 );
566 }
567
568 #[test]
569 fn acquire_returns_none_without_store() {
570 let dir = tempfile::tempdir().unwrap();
572 let id = "proc-x";
573 assert!(acquire_ownership_guard(None, id).is_none());
574 let _ = dir; }
576
577 #[test]
578 fn acquire_rejects_empty_ownership_id() {
579 let (_tmp, store) = tmp_store();
582 assert!(
583 acquire_ownership_guard(Some(&store), "").is_none(),
584 "empty ownership id must never acquire a lock (#13 guard)"
585 );
586 }
587}