Skip to main content

oxi/
lib.rs

1#![warn(missing_docs)]
2// Relax two test-idiom lints under `cfg(test)` so `cargo clippy --all-targets`
3// stays clean without weakening the shipped library:
4//   - `clippy::unwrap_used` — `unwrap()`/`unwrap_err()` are idiomatic in tests;
5//     shipped (non-test) code still `warn`s on it (see the line below).
6//   - `clippy::field_reassign_with_default` — the `let mut x = X::default();
7//     x.f = ..;` test-setup pattern.
8#![warn(clippy::unwrap_used)]
9#![cfg_attr(test, allow(clippy::unwrap_used, clippy::field_reassign_with_default))]
10#![allow(unknown_lints)]
11
12//! oxi: CLI coding harness
13//!
14//! This crate provides the main application logic for the oxi CLI.
15
16// ─── Root-level entry modules ───────────────────────────────────────────────
17// cli must be pub for main.rs binary
18pub mod bootstrap;
19pub mod cli;
20pub mod internal_urls;
21pub mod lsp;
22pub mod main_dispatch;
23pub mod mcp_credentials;
24pub mod print_mode;
25pub mod services;
26pub mod setup_wizard;
27pub mod store;
28
29// ─── Directory groups ───────────────────────────────────────────────────────
30pub(crate) mod app;
31pub(crate) mod context;
32pub mod discovery;
33pub mod extensions; // public for main.rs
34pub(crate) mod infra;
35pub(crate) mod media;
36pub(crate) mod prompt;
37pub mod rpc_mode;
38pub(crate) mod skills;
39pub mod storage; // public for main.rs (packages)
40// Re-exports from storage for main.rs
41pub use storage::packages::PackageManager;
42pub use storage::packages::ResourceKind;
43pub mod tools;
44pub mod tui; // public for main.rs
45pub(crate) mod ui;
46pub(crate) mod util;
47
48///
49/// This is the **new entry point** for oxi-cli run modes. It uses
50/// `oxi-fs` adapters and `OxiBuilder::with_port_*` to construct an
51/// `Oxi` with persistence, auth, config, and skills wired. The legacy
52/// `App::new` path is still used by the interactive TUI during the
53/// migration period.
54///
55/// # Example
56///
57/// ```no_run
58/// use oxi::build_oxi_engine;
59/// # async fn _example() -> anyhow::Result<()> {
60/// let oxi = build_oxi_engine().await?;
61/// println!("providers: {}", oxi.providers().names().len());
62/// # Ok(()) }
63/// ```
64pub async fn build_oxi_engine() -> anyhow::Result<oxi_sdk::Oxi> {
65    let paths = services::OxiPaths::default_paths()?;
66    services::build_oxi(&paths).await
67}
68
69/// Self-check the wired port implementations. Prints a one-line summary
70/// per port and returns `Ok(())` if all are reachable.
71///
72/// Triggered by the `OXI_PORT_CHECK=1` environment variable from
73/// `oxi-cli/src/main.rs`. Useful for verifying the new composition root
74/// without disturbing the legacy `App::new` path.
75pub async fn run_port_check() -> anyhow::Result<()> {
76    let oxi = build_oxi_engine().await?;
77    let ports = oxi.ports();
78
79    // State
80    let entries = ports.state.list("").await?;
81    println!("[state]    entries: {}", entries.len());
82
83    // Auth
84    let providers = ports.auth.list_providers().await?;
85    println!("[auth]     providers with credentials: {:?}", providers);
86
87    // Config
88    let keys = ports.config.list()?;
89    println!("[config]   keys: {}", keys.len());
90
91    // Skills
92    let skills = ports.skills.list().await?;
93    println!("[skills]   {} skill(s) discovered", skills.len());
94    for s in &skills {
95        println!("           - {}: {}", s.name, s.description);
96    }
97
98    // Event bus / memory / etc — all noop unless registered
99    let _ = ports
100        .event_bus
101        .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
102        .await;
103    println!("[event-bus] publish ok (noop bus if not registered)");
104
105    println!("\nport check: ok");
106    Ok(())
107}
108
109/// Context for compaction operations, passed to extension hooks
110#[derive(Debug, Clone)]
111pub struct CompactionContext {
112    /// Messages being compacted
113    pub messages_count: usize,
114    /// Estimated tokens before compaction
115    pub tokens_before: usize,
116    /// Target token count after compaction
117    pub target_tokens: usize,
118    /// Strategy being used
119    pub strategy: String,
120}
121
122impl CompactionContext {
123    /// Create a new compaction context
124    pub fn new(
125        messages_count: usize,
126        tokens_before: usize,
127        target_tokens: usize,
128        strategy: impl Into<String>,
129    ) -> Self {
130        Self {
131            messages_count,
132            tokens_before,
133            target_tokens,
134            strategy: strategy.into(),
135        }
136    }
137
138    /// Get expected compression ratio
139    pub fn compression_ratio(&self) -> f32 {
140        if self.tokens_before == 0 {
141            return 1.0;
142        }
143        self.target_tokens as f32 / self.tokens_before as f32
144    }
145}
146
147// ─── Module-level imports ────────────────────────────────────────────────────
148use crate::store::settings::Settings;
149use anyhow::{Error, Result};
150use oxi_agent::{Agent, AgentConfig, AgentEvent};
151use parking_lot::RwLock;
152use skills::SkillManager;
153use std::sync::Arc;
154
155// ─── Application state ───────────────────────────────────────────────────────
156
157/// Application state and entry point.
158///
159/// Holds an `Oxi` engine (composition root) and a single `Agent` built
160/// from it. The legacy `App::new(settings)` constructor is **gone**;
161/// use [`App::from_oxi`] with a wired `Oxi` from
162/// [`build_oxi_engine`].
163pub struct App {
164    oxi: oxi_sdk::Oxi,
165    agent: Arc<Agent>,
166    settings: Settings,
167    skills: RwLock<SkillManager>,
168    active_skills: RwLock<Vec<String>>,
169    wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
170    ask_bridge: Option<std::sync::Arc<oxi_agent::tools::ask::AskBridge>>,
171    /// Shared local issue store (`.oxi/issues/`). Cloned cheaply (inner `Arc`).
172    /// Used by the agent `issue` tool, the TUI indicator, and the `oxi issue`
173    /// CLI subcommand.
174    issue_store: Option<crate::store::issues::FileIssueStore>,
175    /// Process-wide liveness identity used by every issue-ownership surface
176    /// in this process (agent tool's `ToolContext.session_id`, TUI panel,
177    /// slash-command `/issue` handlers). See
178    /// [`crate::store::issues::liveness::TUI_OWNERSHIP_ID`] for the TUI value.
179    ownership_session_id: String,
180    /// Alive-lock held for the lifetime of `App`. Dropped with `App`, releasing
181    /// the OS-held flock so any other process sees this session as dead once
182    /// we exit (including `kill -9` / crash / normal exit). Only held when
183    /// `issue_store` is available.
184    #[allow(dead_code)]
185    liveness_guard: Option<crate::store::issues::liveness::AliveGuard>,
186    /// Cached `default` persona body, resolved once in `from_oxi` so
187    /// synchronous prompt rebuilds can reuse it without awaiting the port.
188    persona_body: RwLock<Option<String>>,
189}
190
191/// Context for compaction operations, passed to extension hooks
192// ─── System prompt builder ───────────────────────────────────────────────────
193fn build_system_prompt(
194    thinking_level: crate::store::settings::ThinkingLevel,
195    skill_contents: &[String],
196    persona_body: Option<&str>,
197) -> String {
198    let skills: Vec<prompt::system_prompt::Skill> = skill_contents
199        .iter()
200        .enumerate()
201        .map(|(i, content)| prompt::system_prompt::Skill {
202            name: format!("skill-{}", i),
203            content: content.clone(),
204        })
205        .collect();
206
207    let options = prompt::system_prompt::BuildSystemPromptOptions {
208        custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
209        skills,
210        cwd: std::env::current_dir()
211            .map(|p| p.to_string_lossy().to_string())
212            .unwrap_or_default(),
213        persona_prompt: persona_body.map(|s| s.to_string()),
214        ..Default::default()
215    };
216
217    prompt::system_prompt::build_system_prompt(&options)
218}
219
220// ─── App implementation ─────────────────────────────────────────────────────
221
222impl App {
223    /// Build an `App` from a wired `Oxi` engine and a settings object.
224    ///
225    /// The `Oxi` should be created via [`build_oxi_engine`] (or
226    /// `services::build_oxi`) so that all 11 ports are wired. The
227    /// settings hold the user's runtime configuration (model, thinking
228    /// level, etc.).
229    ///
230    /// `ownership_session_id` is the per-process liveness identity used by
231    /// the agent's `issue` tool (`ToolContext.session_id`), the TUI panel,
232    /// and the `/issue` slash command. In TUI mode this MUST equal
233    /// [`crate::store::issues::liveness::TUI_OWNERSHIP_ID`] so the panel and
234    /// agent see the same flock holder. In print / RPC mode, a stable
235    /// process-scoped id (e.g. `proc-<pid>-<uuid>`) is appropriate.
236    pub async fn from_oxi(
237        oxi: oxi_sdk::Oxi,
238        settings: Settings,
239        ownership_session_id: String,
240    ) -> Result<Self> {
241        // Resolve the default persona once from the wired
242        // PersonaProvider port. The body flows into the system prompt;
243        // `preferred_model` overrides the settings default when no
244        // other override exists.
245        let persona = match oxi.ports().personas.get("default").await {
246            Ok(Some(p)) if !p.system_prompt.trim().is_empty() => Some(p),
247            Ok(_) => None,
248            Err(e) => {
249                tracing::warn!(error = %e, "default persona lookup failed");
250                None
251            }
252        };
253
254        let model_id = persona
255            .as_ref()
256            .and_then(|p| p.preferred_model.clone())
257            .or_else(|| settings.effective_model(None))
258            .unwrap_or_default();
259        // Provider-name and api_key lookups removed in 0.55.0 — the SDK
260        // resolver consults the wired AuthProvider port directly.
261
262        let skills_dir = SkillManager::skills_dir().unwrap_or_else(|_| {
263            dirs::home_dir()
264                .unwrap_or_default()
265                .join(".oxi")
266                .join("skills")
267        });
268        let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
269            tracing::debug!("Skills not loaded: {}", e);
270            SkillManager::new()
271        });
272
273        let body_str = persona.as_ref().map(|p| p.system_prompt.clone());
274        let system_prompt = build_system_prompt(settings.thinking_level, &[], body_str.as_deref());
275        let compaction_strategy = if settings.auto_compaction {
276            oxi_sdk::CompactionStrategy::Threshold(0.8)
277        } else {
278            oxi_sdk::CompactionStrategy::Disabled
279        };
280
281        let config = AgentConfig {
282            name: "oxi".to_string(),
283            description: Some("oxi CLI agent".to_string()),
284            model_id: model_id.clone(),
285            system_prompt: Some(system_prompt),
286            timeout_seconds: settings.tool_timeout_seconds,
287            temperature: settings.effective_temperature(),
288            max_tokens: settings.effective_max_tokens(),
289            compaction_strategy,
290            compaction_instruction: None,
291            context_window: 128_000,
292            workspace_dir: Some(
293                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
294            ),
295            output_mode: None,
296            provider_options: None,
297            session_id: Some(ownership_session_id.clone()),
298            ttsr_engine: None,
299            memory: None,
300            todo: None,
301            agent_pool: None,
302            url_resolver: Some(Arc::new(oxi_sdk::SdkUrlResolver::new(
303                oxi.ports().url_router.clone(),
304            ))),
305            // LSP: lazy-spawn rust-analyzer (or other configured
306            // servers) on first request. When no servers are
307            // configured for the workspace, the field stays `None`
308            // and AgentBuilder.build() drops the `lsp` tool from
309            // the registry (see agent_builder.rs::build).
310            lsp: if crate::lsp::manager::default_servers().is_empty() {
311                None
312            } else {
313                Some(Arc::new(crate::lsp::CliLspProvider::with_defaults(
314                    std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
315                )))
316            },
317            ..Default::default()
318        };
319
320        // Build the agent via the SDK's AgentBuilder — no manual wiring.
321        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
322        let agent = oxi
323            .agent(config)
324            .workspace(cwd)
325            .build()
326            .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
327        let agent = Arc::new(agent);
328
329        let ask_timeout = if settings.ask_timeout_secs > 0 {
330            Some(std::time::Duration::from_secs(settings.ask_timeout_secs))
331        } else {
332            None
333        };
334        let bridge =
335            std::sync::Arc::new(oxi_agent::tools::ask::AskBridge::with_timeout(ask_timeout));
336        let ask_tool = oxi_agent::tools::ask::AskTool::new(bridge.clone());
337        agent.tools().register_arc(std::sync::Arc::new(ask_tool));
338        // Open the local issue store rooted at the project (`.oxi/issues/`).
339        // Best-effort: if the directory cannot be resolved, issues are simply
340        // unavailable — the app still works without them. The `/issue` slash
341        // command surfaces a clear error in that case.
342        let issue_store = std::env::current_dir()
343            .ok()
344            .map(|cwd| crate::store::issues::FileIssueStore::open_from_cwd(&cwd))
345            .and_then(|r| {
346                r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
347                    .ok()
348            });
349
350        // Register the `issue` agent tool when the store is available.
351        if let Some(store) = issue_store.clone() {
352            let tool = std::sync::Arc::new(crate::tools::IssueTool::new(store));
353            agent.tools().register_arc(tool);
354        }
355
356        Ok(Self {
357            oxi,
358            agent,
359            settings,
360            skills: RwLock::new(skills),
361            active_skills: RwLock::new(Vec::new()),
362            wasm_ext: None,
363            ask_bridge: Some(bridge),
364            issue_store,
365            ownership_session_id,
366            liveness_guard: None, // set below once issue_store is known
367            persona_body: RwLock::new(persona.as_ref().map(|p| p.system_prompt.clone())),
368        })
369        .map(|mut app| {
370            // Acquire the process-wide liveness flock now that issue_store exists.
371            // Best-effort: another live process already holds the lock is non-fatal;
372            // we still expose ownership_session_id so callers can detect the conflict.
373            app.liveness_guard =
374                acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
375            app
376        })
377    }
378
379    /// Per-process liveness identity. Used by the agent's `issue` tool and any
380    /// other surface that gates on `is_session_alive`.
381    pub fn ownership_session_id(&self) -> &str {
382        &self.ownership_session_id
383    }
384
385    /// True iff `App` holds a live liveness flock under `ownership_session_id`.
386    /// False when there is no `issue_store` (e.g. headless test) or when another
387    /// live process already holds the lock (the assignment feature will surface
388    /// `Assigned` errors in that case — by design).
389    pub fn has_liveness_lock(&self) -> bool {
390        self.liveness_guard.is_some()
391    }
392
393    /// Get the current settings
394    pub fn settings(&self) -> &Settings {
395        &self.settings
396    }
397
398    /// Set the WASM extension manager
399    pub fn set_wasm_ext(
400        &mut self,
401        ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
402    ) {
403        self.wasm_ext = ext;
404    }
405
406    /// Get the WASM extension manager
407    pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
408        self.wasm_ext.as_ref()
409    }
410
411    /// Get a clone of the local issue store, if one was opened successfully.
412    pub fn issue_store(&self) -> Option<crate::store::issues::FileIssueStore> {
413        self.issue_store.clone()
414    }
415
416    /// Get a reference to the underlying `Oxi` engine. The catalog port and
417    /// other ports are accessible through it.
418    pub fn oxi(&self) -> &oxi_sdk::Oxi {
419        &self.oxi
420    }
421
422    /// Get a reference to the underlying agent.
423    pub fn agent(&self) -> Arc<Agent> {
424        Arc::clone(&self.agent)
425    }
426
427    /// Get the tool registry (for registering extension tools)
428    pub fn agent_tools(&self) -> Arc<oxi_agent::ToolRegistry> {
429        self.agent.tools()
430    }
431
432    /// Get the ask bridge, if initialized.
433    pub fn ask_bridge(&self) -> Option<&std::sync::Arc<oxi_agent::tools::ask::AskBridge>> {
434        self.ask_bridge.as_ref()
435    }
436
437    /// Get a reference to the skill manager
438    pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
439        self.skills.read()
440    }
441
442    /// Activate a skill by name. Returns an error string if not found.
443    pub fn activate_skill(&self, name: &str) -> Result<(), String> {
444        {
445            let skills = self.skills.read();
446            if skills.get(name).is_none() {
447                return Err(format!("Skill '{}' not found", name));
448            }
449        }
450        let name_lower = name.to_lowercase();
451        {
452            let mut active = self.active_skills.write();
453            if !active.contains(&name_lower) {
454                active.push(name_lower);
455            }
456        }
457        self.rebuild_system_prompt();
458        Ok(())
459    }
460
461    /// Deactivate a skill by name.
462    pub fn deactivate_skill(&self, name: &str) {
463        let name_lower = name.to_lowercase();
464        {
465            let mut active = self.active_skills.write();
466            active.retain(|n| n != &name_lower);
467        }
468        self.rebuild_system_prompt();
469    }
470
471    /// List currently active skill names
472    pub fn active_skills(&self) -> Vec<String> {
473        self.active_skills.read().clone()
474    }
475
476    /// Rebuild the system prompt with current active skills
477    fn rebuild_system_prompt(&self) {
478        let active = self.active_skills.read();
479        let skills = self.skills.read();
480        let contents: Vec<String> = active
481            .iter()
482            .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
483            .collect();
484        // The persona body was resolved once in `from_oxi` and cached
485        // on `self.persona_body` so this sync rebuild can include it
486        // without re-awaiting the async PersonaProvider port.
487        let persona = self.persona_body.read().clone();
488        let prompt =
489            build_system_prompt(self.settings.thinking_level, &contents, persona.as_deref());
490        self.agent.set_system_prompt(prompt);
491    }
492
493    /// Get a clone of the current state
494    pub fn agent_state(&self) -> oxi_agent::AgentState {
495        self.agent.state()
496    }
497
498    /// Run a single prompt and return the response
499    pub async fn run_prompt(&self, prompt: String) -> Result<String> {
500        let (response, _events) = self.agent.run(prompt).await?;
501        Ok(response.content)
502    }
503
504    /// Run a prompt with event callback
505    pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
506    where
507        F: FnMut(AgentEvent) + Send + 'static,
508    {
509        self.agent.run_streaming(prompt, on_event).await?;
510        let state = self.agent_state();
511        for msg in state.messages.iter().rev() {
512            if let oxi_sdk::Message::Assistant(a) = msg {
513                return Ok(a.text_content());
514            }
515        }
516        Ok(String::new())
517    }
518
519    /// Reset the conversation
520    pub fn reset(&self) {
521        self.agent.reset();
522    }
523
524    /// Switch the model used for future LLM calls.
525    ///
526    /// The new provider is re-credentialed by the SDK resolver via the
527    /// wired AuthProvider port; the `api_key` parameter was removed in
528    /// 0.55.0 (issues #39/#40).
529    pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
530        let _ = self.agent.switch_model(model_id);
531        Ok(())
532    }
533
534    /// Get the current model ID
535    pub fn model_id(&self) -> String {
536        self.agent.model_id()
537    }
538}
539
540/// Acquire the process-wide liveness flock for `ownership_id` under the issue
541/// store's `.alive/` directory.
542///
543/// Returns `None` (no lock) when there is no issue store or when another live
544/// process already holds the lock — both non-fatal; the caller can still read
545/// `ownership_session_id` and the assignment feature will surface `Assigned`
546/// errors if contention actually occurs.
547///
548/// Extracted from `App::from_oxi` so the single-lock invariant (defect #13 fix)
549/// can be unit-tested without standing up a full `Oxi` engine.
550pub(crate) fn acquire_ownership_guard(
551    issue_store: Option<&crate::store::issues::FileIssueStore>,
552    ownership_id: &str,
553) -> Option<crate::store::issues::liveness::AliveGuard> {
554    let store = issue_store?;
555    if ownership_id.is_empty() {
556        // Defensive: never hold a lock under the empty string — that was the
557        // #13 bug shape (empty owner is never alive, so ownership was bypassed).
558        return None;
559    }
560    crate::store::issues::liveness::acquire(&store.issues_dir(), ownership_id).ok()
561}
562
563#[cfg(test)]
564mod tests {
565    //! P0 regression: `App` must hold exactly one liveness flock under its
566    //! ownership identity. We test the extracted `acquire_ownership_guard`
567    //! helper (the single chokepoint `from_oxi` delegates to) rather than
568    //! standing up a full `Oxi` engine.
569    use super::*;
570    use crate::store::issues::FileIssueStore;
571    use crate::store::issues::liveness;
572
573    fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
574        let tmp = tempfile::tempdir().unwrap();
575        let dir = tmp.path().join(".oxi").join("issues");
576        std::fs::create_dir_all(&dir).unwrap();
577        (tmp, FileIssueStore::open(dir).unwrap())
578    }
579
580    #[test]
581    fn app_holds_single_liveness_lock() {
582        // The #13 invariant: acquiring the ownership guard makes the session
583        // live under that identity, and a second acquire under the SAME id
584        // fails (one flock per identity — single lock).
585        let (_tmp, store) = tmp_store();
586        let dir = store.issues_dir();
587        let id = "proc-test-app";
588
589        let guard = acquire_ownership_guard(Some(&store), id);
590        assert!(
591            guard.is_some(),
592            "App must acquire the liveness lock for its ownership id"
593        );
594        assert!(
595            liveness::is_session_alive(&dir, id),
596            "after acquire, the session must be live"
597        );
598
599        // While held, the same identity cannot be acquired again — single lock.
600        let second = liveness::acquire(&dir, id);
601        assert!(second.is_err(), "second acquire under same id must fail");
602
603        drop(guard);
604        assert!(
605            !liveness::is_session_alive(&dir, id),
606            "dropping App's guard releases the lock"
607        );
608    }
609
610    #[test]
611    fn acquire_returns_none_without_store() {
612        // No issue store (headless/test) → no lock. Not an error.
613        let dir = tempfile::tempdir().unwrap();
614        let id = "proc-x";
615        assert!(acquire_ownership_guard(None, id).is_none());
616        let _ = dir; // no store created
617    }
618
619    #[test]
620    fn acquire_rejects_empty_ownership_id() {
621        // Defensive guard against the #13 bug shape: never hold a lock under
622        // the empty string (it's never alive, so ownership would be bypassed).
623        let (_tmp, store) = tmp_store();
624        assert!(
625            acquire_ownership_guard(Some(&store), "").is_none(),
626            "empty ownership id must never acquire a lock (#13 guard)"
627        );
628    }
629}