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 internal_urls;
21pub mod main_dispatch;
22pub mod mcp_credentials;
23pub mod print_mode;
24pub mod services;
25pub mod setup_wizard;
26pub mod store;
27
28pub(crate) mod app;
30pub(crate) mod context;
31pub mod discovery;
32pub mod extensions; pub(crate) mod infra;
34pub(crate) mod media;
35pub(crate) mod prompt;
36pub(crate) mod rpc_mode;
37pub(crate) mod skills;
38pub mod storage; pub use storage::packages::PackageManager;
41pub use storage::packages::ResourceKind;
42pub mod tools;
43pub mod tui; pub(crate) mod ui;
45pub(crate) mod util;
46
47pub async fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
64 let paths = services::OxiPaths::default_paths()?;
65 services::build_oxi(&paths).await
66}
67
68pub async fn run_port_check() -> anyhow::Result<()> {
75 let oxi = build_oxi_engine().await?;
76 let ports = oxi.ports();
77
78 let entries = ports.state.list("").await?;
80 println!("[state] entries: {}", entries.len());
81
82 let providers = ports.auth.list_providers().await?;
84 println!("[auth] providers with credentials: {:?}", providers);
85
86 let keys = ports.config.list()?;
88 println!("[config] keys: {}", keys.len());
89
90 let skills = ports.skills.list().await?;
92 println!("[skills] {} skill(s) discovered", skills.len());
93 for s in &skills {
94 println!(" - {}: {}", s.name, s.description);
95 }
96
97 let _ = ports
99 .event_bus
100 .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
101 .await;
102 println!("[event-bus] publish ok (noop bus if not registered)");
103
104 println!("\nport check: ok");
105 Ok(())
106}
107
108#[derive(Debug, Clone)]
110pub struct CompactionContext {
111 pub messages_count: usize,
113 pub tokens_before: usize,
115 pub target_tokens: usize,
117 pub strategy: String,
119}
120
121impl CompactionContext {
122 pub fn new(
124 messages_count: usize,
125 tokens_before: usize,
126 target_tokens: usize,
127 strategy: impl Into<String>,
128 ) -> Self {
129 Self {
130 messages_count,
131 tokens_before,
132 target_tokens,
133 strategy: strategy.into(),
134 }
135 }
136
137 pub fn compression_ratio(&self) -> f32 {
139 if self.tokens_before == 0 {
140 return 1.0;
141 }
142 self.target_tokens as f32 / self.tokens_before as f32
143 }
144}
145
146use crate::store::settings::Settings;
148use anyhow::{Error, Result};
149use oxi_agent::{Agent, AgentConfig, AgentEvent};
150use parking_lot::RwLock;
151use skills::SkillManager;
152use std::sync::Arc;
153
154pub struct App {
163 oxi: oxi_sdk::Oxi,
164 agent: Arc<Agent>,
165 settings: Settings,
166 skills: RwLock<SkillManager>,
167 active_skills: RwLock<Vec<String>>,
168 wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
169 ask_bridge: Option<std::sync::Arc<oxi_agent::tools::ask::AskBridge>>,
170 issue_store: Option<crate::store::issues::FileIssueStore>,
174 ownership_session_id: String,
179 #[allow(dead_code)]
184 liveness_guard: Option<crate::store::issues::liveness::AliveGuard>,
185}
186
187fn build_system_prompt(
190 thinking_level: crate::store::settings::ThinkingLevel,
191 skill_contents: &[String],
192) -> String {
193 let skills: Vec<prompt::system_prompt::Skill> = skill_contents
194 .iter()
195 .enumerate()
196 .map(|(i, content)| prompt::system_prompt::Skill {
197 name: format!("skill-{}", i),
198 content: content.clone(),
199 })
200 .collect();
201
202 let options = prompt::system_prompt::BuildSystemPromptOptions {
203 custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
204 skills,
205 cwd: std::env::current_dir()
206 .map(|p| p.to_string_lossy().to_string())
207 .unwrap_or_default(),
208 ..Default::default()
209 };
210
211 prompt::system_prompt::build_system_prompt(&options)
212}
213
214impl App {
217 pub async fn from_oxi(
233 oxi: oxi_sdk::Oxi,
234 settings: Settings,
235 ownership_session_id: String,
236 ) -> Result<Self> {
237 let model_id = settings.effective_model(None).unwrap_or_default();
238 let provider_name = settings
239 .effective_provider(None)
240 .unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
241
242 let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
244
245 let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
246 dirs::home_dir()
247 .unwrap_or_default()
248 .join(".oxi")
249 .join("skills")
250 });
251 let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
252 tracing::debug!("Skills not loaded: {}", e);
253 SkillManager::new()
254 });
255
256 let system_prompt = build_system_prompt(settings.thinking_level, &[]);
257 let compaction_strategy = if settings.auto_compaction {
258 oxi_sdk::CompactionStrategy::Threshold(0.8)
259 } else {
260 oxi_sdk::CompactionStrategy::Disabled
261 };
262
263 let config = AgentConfig {
264 name: "oxi".to_string(),
265 description: Some("oxi CLI agent".to_string()),
266 model_id: model_id.clone(),
267 system_prompt: Some(system_prompt),
268 timeout_seconds: settings.tool_timeout_seconds,
269 temperature: settings.effective_temperature(),
270 max_tokens: settings.effective_max_tokens(),
271 compaction_strategy,
272 compaction_instruction: None,
273 context_window: 128_000,
274 api_key,
275 workspace_dir: Some(
276 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
277 ),
278 output_mode: None,
279 provider_options: None,
280 session_id: Some(ownership_session_id.clone()),
281 ttsr_engine: None,
282 memory: None,
283 todo: None,
284 agent_pool: None,
285 ..Default::default()
286 };
287
288 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
290 let agent = oxi
291 .agent(config)
292 .workspace(cwd)
293 .build()
294 .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
295 let agent = Arc::new(agent);
296
297 let ask_timeout = if settings.ask_timeout_secs > 0 {
298 Some(std::time::Duration::from_secs(settings.ask_timeout_secs))
299 } else {
300 None
301 };
302 let bridge =
303 std::sync::Arc::new(oxi_agent::tools::ask::AskBridge::with_timeout(ask_timeout));
304 let ask_tool = oxi_agent::tools::ask::AskTool::new(bridge.clone());
305 agent.tools().register_arc(std::sync::Arc::new(ask_tool));
306 let issue_store = std::env::current_dir()
311 .ok()
312 .map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
313 .and_then(|r| {
314 r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
315 .ok()
316 });
317
318 if let Some(store) = issue_store.clone() {
320 let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
321 agent.tools().register_arc(tool);
322 }
323
324 Ok(Self {
325 oxi,
326 agent,
327 settings,
328 skills: RwLock::new(skills),
329 active_skills: RwLock::new(Vec::new()),
330 wasm_ext: None,
331 ask_bridge: Some(bridge),
332 issue_store,
333 ownership_session_id,
334 liveness_guard: None, })
336 .map(|mut app| {
337 app.liveness_guard =
341 acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
342 app
343 })
344 }
345
346 pub fn ownership_session_id(&self) -> &str {
349 &self.ownership_session_id
350 }
351
352 pub fn has_liveness_lock(&self) -> bool {
357 self.liveness_guard.is_some()
358 }
359
360 pub fn settings(&self) -> &Settings {
362 &self.settings
363 }
364
365 pub fn set_wasm_ext(
367 &mut self,
368 ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
369 ) {
370 self.wasm_ext = ext;
371 }
372
373 pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
375 self.wasm_ext.as_ref()
376 }
377
378 pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
380 self.issue_store.clone()
381 }
382
383 pub fn oxi(&self) -> &oxi_sdk::Oxi {
386 &self.oxi
387 }
388
389 pub fn agent(&self) -> Arc<Agent> {
391 Arc::clone(&self.agent)
392 }
393
394 pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
396 self.agent.tools()
397 }
398
399 pub fn ask_bridge(&self) -> Option<&std::sync::Arc<oxi_agent::tools::ask::AskBridge>> {
401 self.ask_bridge.as_ref()
402 }
403
404 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
406 self.skills.read()
407 }
408
409 pub fn activate_skill(&self, name: &str) -> Result<(), String> {
411 {
412 let skills = self.skills.read();
413 if skills.get(name).is_none() {
414 return Err(format!("Skill '{}' not found", name));
415 }
416 }
417 let name_lower = name.to_lowercase();
418 {
419 let mut active = self.active_skills.write();
420 if !active.contains(&name_lower) {
421 active.push(name_lower);
422 }
423 }
424 self.rebuild_system_prompt();
425 Ok(())
426 }
427
428 pub fn deactivate_skill(&self, name: &str) {
430 let name_lower = name.to_lowercase();
431 {
432 let mut active = self.active_skills.write();
433 active.retain(|n| n != &name_lower);
434 }
435 self.rebuild_system_prompt();
436 }
437
438 pub fn active_skills(&self) -> Vec<String> {
440 self.active_skills.read().clone()
441 }
442
443 fn rebuild_system_prompt(&self) {
445 let active = self.active_skills.read();
446 let skills = self.skills.read();
447 let contents: Vec<String> = active
448 .iter()
449 .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
450 .collect();
451 let prompt = build_system_prompt(self.settings.thinking_level, &contents);
452 self.agent.set_system_prompt(prompt);
453 }
454
455 pub fn agent_state(&self) -> oxi_agent::AgentState {
457 self.agent.state()
458 }
459
460 pub async fn run_prompt(&self, prompt: String) -> Result<String> {
462 let (response, _events) = self.agent.run(prompt).await?;
463 Ok(response.content)
464 }
465
466 pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
468 where
469 F: FnMut(AgentEvent) + Send + 'static,
470 {
471 self.agent.run_streaming(prompt, on_event).await?;
472 let state = self.agent_state();
473 for msg in state.messages.iter().rev() {
474 if let oxi_sdk::Message::Assistant(a) = msg {
475 return Ok(a.text_content());
476 }
477 }
478 Ok(String::new())
479 }
480
481 pub fn reset(&self) {
483 self.agent.reset();
484 }
485
486 pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
488 let parts: Vec<&str> = model_id.split('/').collect();
489 let provider = parts
490 .first()
491 .map(|s| s.to_string())
492 .unwrap_or_else(|| "anthropic".to_string());
493 let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
494 let _ = self.agent.switch_model(model_id, api_key);
495 Ok(())
496 }
497
498 pub fn model_id(&self) -> String {
500 self.agent.model_id()
501 }
502}
503
504pub(crate) fn acquire_ownership_guard(
515 issue_store: Option<&crate::store::issues::FileIssueStore>,
516 ownership_id: &str,
517) -> Option<crate::store::issues::liveness::AliveGuard> {
518 let store = issue_store?;
519 if ownership_id.is_empty() {
520 return None;
523 }
524 crate::store::issues::liveness::acquire(&store.issues_dir(), ownership_id).ok()
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
534 use crate::store::issues::FileIssueStore;
535 use crate::store::issues::liveness;
536
537 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
538 let tmp = tempfile::tempdir().unwrap();
539 let dir = tmp.path().join(".oxi").join("issues");
540 std::fs::create_dir_all(&dir).unwrap();
541 (tmp, FileIssueStore::open(dir).unwrap())
542 }
543
544 #[test]
545 fn app_holds_single_liveness_lock() {
546 let (_tmp, store) = tmp_store();
550 let dir = store.issues_dir();
551 let id = "proc-test-app";
552
553 let guard = acquire_ownership_guard(Some(&store), id);
554 assert!(
555 guard.is_some(),
556 "App must acquire the liveness lock for its ownership id"
557 );
558 assert!(
559 liveness::is_session_alive(&dir, id),
560 "after acquire, the session must be live"
561 );
562
563 let second = liveness::acquire(&dir, id);
565 assert!(second.is_err(), "second acquire under same id must fail");
566
567 drop(guard);
568 assert!(
569 !liveness::is_session_alive(&dir, id),
570 "dropping App's guard releases the lock"
571 );
572 }
573
574 #[test]
575 fn acquire_returns_none_without_store() {
576 let dir = tempfile::tempdir().unwrap();
578 let id = "proc-x";
579 assert!(acquire_ownership_guard(None, id).is_none());
580 let _ = dir; }
582
583 #[test]
584 fn acquire_rejects_empty_ownership_id() {
585 let (_tmp, store) = tmp_store();
588 assert!(
589 acquire_ownership_guard(Some(&store), "").is_none(),
590 "empty ownership id must never acquire a lock (#13 guard)"
591 );
592 }
593}