#![warn(missing_docs)]
#![warn(clippy::unwrap_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::field_reassign_with_default))]
#![allow(unknown_lints)]
pub mod bootstrap;
pub mod cli;
pub mod internal_urls;
pub mod main_dispatch;
pub mod mcp_credentials;
pub mod print_mode;
pub mod services;
pub mod setup_wizard;
pub mod store;
pub(crate) mod app;
pub(crate) mod context;
pub mod discovery;
pub mod extensions; pub(crate) mod infra;
pub(crate) mod media;
pub(crate) mod prompt;
pub(crate) mod rpc_mode;
pub(crate) mod skills;
pub mod storage; pub use storage::packages::PackageManager;
pub use storage::packages::ResourceKind;
pub mod tools;
pub mod tui; pub(crate) mod ui;
pub(crate) mod util;
pub async fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
let paths = services::OxiPaths::default_paths()?;
services::build_oxi(&paths).await
}
pub async fn run_port_check() -> anyhow::Result<()> {
let oxi = build_oxi_engine().await?;
let ports = oxi.ports();
let entries = ports.state.list("").await?;
println!("[state] entries: {}", entries.len());
let providers = ports.auth.list_providers().await?;
println!("[auth] providers with credentials: {:?}", providers);
let keys = ports.config.list()?;
println!("[config] keys: {}", keys.len());
let skills = ports.skills.list().await?;
println!("[skills] {} skill(s) discovered", skills.len());
for s in &skills {
println!(" - {}: {}", s.name, s.description);
}
let _ = ports
.event_bus
.publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
.await;
println!("[event-bus] publish ok (noop bus if not registered)");
println!("\nport check: ok");
Ok(())
}
#[derive(Debug, Clone)]
pub struct CompactionContext {
pub messages_count: usize,
pub tokens_before: usize,
pub target_tokens: usize,
pub strategy: String,
}
impl CompactionContext {
pub fn new(
messages_count: usize,
tokens_before: usize,
target_tokens: usize,
strategy: impl Into<String>,
) -> Self {
Self {
messages_count,
tokens_before,
target_tokens,
strategy: strategy.into(),
}
}
pub fn compression_ratio(&self) -> f32 {
if self.tokens_before == 0 {
return 1.0;
}
self.target_tokens as f32 / self.tokens_before as f32
}
}
use crate::store::settings::Settings;
use anyhow::{Error, Result};
use oxi_agent::{Agent, AgentConfig, AgentEvent};
use parking_lot::RwLock;
use skills::SkillManager;
use std::sync::Arc;
pub struct App {
oxi: oxi_sdk::Oxi,
agent: Arc<Agent>,
settings: Settings,
skills: RwLock<SkillManager>,
active_skills: RwLock<Vec<String>>,
wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
questionnaire_bridge:
Option<std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>>,
issue_store: Option<crate::store::issues::FileIssueStore>,
ownership_session_id: String,
#[allow(dead_code)]
liveness_guard: Option<crate::store::issues::liveness::AliveGuard>,
}
fn build_system_prompt(
thinking_level: crate::store::settings::ThinkingLevel,
skill_contents: &[String],
) -> String {
let skills: Vec<prompt::system_prompt::Skill> = skill_contents
.iter()
.enumerate()
.map(|(i, content)| prompt::system_prompt::Skill {
name: format!("skill-{}", i),
content: content.clone(),
})
.collect();
let options = prompt::system_prompt::BuildSystemPromptOptions {
custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
skills,
cwd: std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
..Default::default()
};
prompt::system_prompt::build_system_prompt(&options)
}
impl App {
pub async fn from_oxi(
oxi: oxi_sdk::Oxi,
settings: Settings,
ownership_session_id: String,
) -> Result<Self> {
let model_id = settings.effective_model(None).unwrap_or_default();
let provider_name = settings
.effective_provider(None)
.unwrap_or_else(|| model_id.split('/').next().unwrap_or("").to_string());
let api_key = oxi.ports().auth.get_api_key(&provider_name).await?;
let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_default()
.join(".oxi")
.join("skills")
});
let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
tracing::debug!("Skills not loaded: {}", e);
SkillManager::new()
});
let system_prompt = build_system_prompt(settings.thinking_level, &[]);
let compaction_strategy = if settings.auto_compaction {
oxi_sdk::CompactionStrategy::Threshold(0.8)
} else {
oxi_sdk::CompactionStrategy::Disabled
};
let config = AgentConfig {
name: "oxi".to_string(),
description: Some("oxi CLI agent".to_string()),
model_id: model_id.clone(),
system_prompt: Some(system_prompt),
timeout_seconds: settings.tool_timeout_seconds,
temperature: settings.effective_temperature(),
max_tokens: settings.effective_max_tokens(),
compaction_strategy,
compaction_instruction: None,
context_window: 128_000,
api_key,
workspace_dir: Some(
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
),
output_mode: None,
provider_options: None,
session_id: Some(ownership_session_id.clone()),
ttsr_engine: None,
memory: None,
todo: None,
agent_pool: None,
};
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let agent = oxi
.agent(config)
.workspace(cwd)
.build()
.map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
let agent = Arc::new(agent);
let questionnaire_timeout = if settings.questionnaire_timeout_secs > 0 {
Some(std::time::Duration::from_secs(
settings.questionnaire_timeout_secs,
))
} else {
None
};
let bridge = std::sync::Arc::new(
oxi_agent::tools::questionnaire::QuestionnaireBridge::with_timeout(
questionnaire_timeout,
),
);
let questionnaire_tool =
oxi_agent::tools::questionnaire::QuestionnaireTool::new(bridge.clone());
agent
.tools()
.register_arc(std::sync::Arc::new(questionnaire_tool));
let issue_store = std::env::current_dir()
.ok()
.map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
.and_then(|r| {
r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
.ok()
});
if let Some(store) = issue_store.clone() {
let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
agent.tools().register_arc(tool);
}
Ok(Self {
oxi,
agent,
settings,
skills: RwLock::new(skills),
active_skills: RwLock::new(Vec::new()),
wasm_ext: None,
questionnaire_bridge: Some(bridge),
issue_store,
ownership_session_id,
liveness_guard: None, })
.map(|mut app| {
app.liveness_guard =
acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
app
})
}
pub fn ownership_session_id(&self) -> &str {
&self.ownership_session_id
}
pub fn has_liveness_lock(&self) -> bool {
self.liveness_guard.is_some()
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn set_wasm_ext(
&mut self,
ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
) {
self.wasm_ext = ext;
}
pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
self.wasm_ext.as_ref()
}
pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
self.issue_store.clone()
}
pub fn oxi(&self) -> &oxi_sdk::Oxi {
&self.oxi
}
pub fn agent(&self) -> Arc<Agent> {
Arc::clone(&self.agent)
}
pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
self.agent.tools()
}
pub fn questionnaire_bridge(
&self,
) -> Option<&std::sync::Arc<oxi_agent::tools::questionnaire::QuestionnaireBridge>> {
self.questionnaire_bridge.as_ref()
}
pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
self.skills.read()
}
pub fn activate_skill(&self, name: &str) -> Result<(), String> {
{
let skills = self.skills.read();
if skills.get(name).is_none() {
return Err(format!("Skill '{}' not found", name));
}
}
let name_lower = name.to_lowercase();
{
let mut active = self.active_skills.write();
if !active.contains(&name_lower) {
active.push(name_lower);
}
}
self.rebuild_system_prompt();
Ok(())
}
pub fn deactivate_skill(&self, name: &str) {
let name_lower = name.to_lowercase();
{
let mut active = self.active_skills.write();
active.retain(|n| n != &name_lower);
}
self.rebuild_system_prompt();
}
pub fn active_skills(&self) -> Vec<String> {
self.active_skills.read().clone()
}
fn rebuild_system_prompt(&self) {
let active = self.active_skills.read();
let skills = self.skills.read();
let contents: Vec<String> = active
.iter()
.filter_map(|name| skills.get(name).map(|s| s.content.clone()))
.collect();
let prompt = build_system_prompt(self.settings.thinking_level, &contents);
self.agent.set_system_prompt(prompt);
}
pub fn agent_state(&self) -> oxi_agent::AgentState {
self.agent.state()
}
pub async fn run_prompt(&self, prompt: String) -> Result<String> {
let (response, _events) = self.agent.run(prompt).await?;
Ok(response.content)
}
pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
where
F: FnMut(AgentEvent) + Send + 'static,
{
self.agent.run_streaming(prompt, on_event).await?;
let state = self.agent_state();
for msg in state.messages.iter().rev() {
if let oxi_sdk::Message::Assistant(a) = msg {
return Ok(a.text_content());
}
}
Ok(String::new())
}
pub fn reset(&self) {
self.agent.reset();
}
pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
let parts: Vec<&str> = model_id.split('/').collect();
let provider = parts
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| "anthropic".to_string());
let api_key = self.oxi.ports().auth.get_api_key(&provider).await?;
let _ = self.agent.switch_model(model_id, api_key);
Ok(())
}
pub fn model_id(&self) -> String {
self.agent.model_id()
}
}
pub(crate) fn acquire_ownership_guard(
issue_store: Option<&crate::store::issues::FileIssueStore>,
ownership_id: &str,
) -> Option<crate::store::issues::liveness::AliveGuard> {
let store = issue_store?;
if ownership_id.is_empty() {
return None;
}
crate::store::issues::liveness::acquire(&store.issues_dir(), ownership_id).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::issues::FileIssueStore;
use crate::store::issues::liveness;
fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".oxi").join("issues");
std::fs::create_dir_all(&dir).unwrap();
(tmp, FileIssueStore::open(dir).unwrap())
}
#[test]
fn app_holds_single_liveness_lock() {
let (_tmp, store) = tmp_store();
let dir = store.issues_dir();
let id = "proc-test-app";
let guard = acquire_ownership_guard(Some(&store), id);
assert!(
guard.is_some(),
"App must acquire the liveness lock for its ownership id"
);
assert!(
liveness::is_session_alive(&dir, id),
"after acquire, the session must be live"
);
let second = liveness::acquire(&dir, id);
assert!(second.is_err(), "second acquire under same id must fail");
drop(guard);
assert!(
!liveness::is_session_alive(&dir, id),
"dropping App's guard releases the lock"
);
}
#[test]
fn acquire_returns_none_without_store() {
let dir = tempfile::tempdir().unwrap();
let id = "proc-x";
assert!(acquire_ownership_guard(None, id).is_none());
let _ = dir; }
#[test]
fn acquire_rejects_empty_ownership_id() {
let (_tmp, store) = tmp_store();
assert!(
acquire_ownership_guard(Some(&store), "").is_none(),
"empty ownership id must never acquire a lock (#13 guard)"
);
}
}