1#![allow(
4 missing_docs,
5 dead_code,
6 clippy::field_reassign_with_default,
7 clippy::unwrap_used,
8 clippy::let_and_return,
9 clippy::borrow_interior_mutable_const,
10 clippy::derivable_impls,
11 clippy::new_without_default,
12 unknown_lints
13)]
14pub mod behavior;
21pub mod bootstrap;
22pub mod cli;
23pub mod foundation;
24pub mod home_migrate;
25pub mod internal_urls;
26pub mod lsp;
27pub mod main_dispatch;
28pub mod managed_install;
29pub mod mcp_credentials;
30pub mod oauth_listener;
31pub mod oauth_refresh;
32pub mod print_mode;
33pub mod provider_oauth;
34pub mod services;
35pub mod setup_wizard;
36pub mod store;
37pub(crate) mod app;
39pub(crate) mod context;
40pub mod discovery;
41pub mod extensions; pub(crate) mod infra;
43pub(crate) mod media;
44pub(crate) mod prompt;
45pub mod rpc_mode;
46pub(crate) mod skills;
47pub mod storage; pub use storage::packages::PackageManager;
50pub use storage::packages::ResourceKind;
51pub mod tools;
52pub(crate) mod ui;
53pub(crate) mod util;
54
55pub async fn build_oxicode_engine(
70 embedding_provider: Option<std::sync::Arc<dyn oxicode_sdk::ports::EmbeddingProvider>>,
71 hook_runner: Option<std::sync::Arc<dyn oxicode_sdk::ports::HookRunner>>,
72) -> anyhow::Result<oxicode_sdk::Oxicode> {
73 let paths = services::OxicodePaths::default_paths()?;
74 services::build_oxicode(&paths, embedding_provider, hook_runner).await
75}
76
77pub async fn run_port_check() -> anyhow::Result<()> {
84 let oxicode = build_oxicode_engine(None, None).await?;
85 let ports = oxicode.ports();
86
87 let entries = ports.state.list("").await?;
88 println!("[state] entries: {}", entries.len());
89
90 let providers = ports.auth.list_providers().await?;
92 println!("[auth] providers with credentials: {:?}", providers);
93
94 let keys = ports.config.list()?;
96 println!("[config] keys: {}", keys.len());
97
98 let skills = ports.skills.list().await?;
100 println!("[skills] {} skill(s) discovered", skills.len());
101 for s in &skills {
102 println!(" - {}: {}", s.name, s.description);
103 }
104
105 let _ = ports
107 .event_bus
108 .publish(&"port-check".to_string(), serde_json::json!({"ok": true}))
109 .await;
110 println!("[event-bus] publish ok (noop bus if not registered)");
111
112 println!("\nport check: ok");
113 Ok(())
114}
115
116#[derive(Debug, Clone)]
118pub struct CompactionContext {
119 pub messages_count: usize,
121 pub tokens_before: usize,
123 pub target_tokens: usize,
125 pub strategy: String,
127}
128
129impl CompactionContext {
130 pub fn new(
132 messages_count: usize,
133 tokens_before: usize,
134 target_tokens: usize,
135 strategy: impl Into<String>,
136 ) -> Self {
137 Self {
138 messages_count,
139 tokens_before,
140 target_tokens,
141 strategy: strategy.into(),
142 }
143 }
144
145 pub fn compression_ratio(&self) -> f32 {
147 if self.tokens_before == 0 {
148 return 1.0;
149 }
150 self.target_tokens as f32 / self.tokens_before as f32
151 }
152}
153
154use crate::store::settings::Settings;
156use anyhow::{Error, Result};
157use oxicode_agent::{Agent, AgentConfig, AgentEvent};
158use parking_lot::RwLock;
159use skills::SkillManager;
160use std::collections::VecDeque;
161use std::sync::Arc;
162
163#[derive(Clone)]
174pub struct SessionState {
175 pub should_stop: Arc<std::sync::atomic::AtomicBool>,
179 pub steering: Arc<RwLock<VecDeque<oxicode_sdk::Message>>>,
181 pub follow_up: Arc<RwLock<VecDeque<oxicode_sdk::Message>>>,
183}
184
185impl Default for SessionState {
186 fn default() -> Self {
187 Self {
188 should_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)),
189 steering: Arc::new(RwLock::new(VecDeque::new())),
190 follow_up: Arc::new(RwLock::new(VecDeque::new())),
191 }
192 }
193}
194
195pub struct App {
202 oxicode: oxicode_sdk::Oxicode,
203 agent: Arc<Agent>,
204 settings: Settings,
205 skills: RwLock<SkillManager>,
206 active_skills: RwLock<Vec<String>>,
207 wasm_ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
208 ask_bridge: Option<std::sync::Arc<oxicode_agent::tools::ask::AskBridge>>,
209 issue_store: Option<oxicode_sdk::FileIssueStore>,
213 ownership_session_id: String,
218 #[allow(dead_code)]
223 liveness_guard: Option<oxicode_sdk::liveness::AliveGuard>,
224 persona_body: RwLock<Option<String>>,
227 session_state: SessionState,
231 behavior: Option<crate::behavior::BehaviorComposition>,
234}
235fn build_system_prompt(
237 thinking_level: crate::store::settings::ThinkingLevel,
238 skill_contents: &[String],
239 persona_body: Option<&str>,
240) -> String {
241 let skills: Vec<prompt::system_prompt::Skill> = skill_contents
242 .iter()
243 .enumerate()
244 .map(|(i, content)| prompt::system_prompt::Skill {
245 name: format!("skill-{}", i),
246 content: content.clone(),
247 })
248 .collect();
249
250 let options = prompt::system_prompt::BuildSystemPromptOptions {
251 custom_prompt: prompt::system_prompt::thinking_level_prompt(thinking_level),
252 skills,
253 cwd: std::env::current_dir()
254 .map(|p| p.to_string_lossy().to_string())
255 .unwrap_or_default(),
256 persona_prompt: persona_body.map(|s| s.to_string()),
257 ..Default::default()
258 };
259
260 prompt::system_prompt::build_system_prompt(&options)
261}
262
263impl App {
266 pub async fn from_oxicode(
286 oxicode: oxicode_sdk::Oxicode,
287 settings: Settings,
288 ownership_session_id: String,
289 session_state: Option<SessionState>,
290 behavior: Option<crate::behavior::BehaviorComposition>,
291 ) -> Result<Self> {
292 let session_state = session_state.unwrap_or_default();
293 let persona = match oxicode.ports().personas.get("default").await {
298 Ok(Some(p)) if !p.system_prompt.trim().is_empty() => Some(p),
299 Ok(_) => None,
300 Err(e) => {
301 tracing::warn!(error = %e, "default persona lookup failed");
302 None
303 }
304 };
305
306 let model_id = persona
307 .as_ref()
308 .and_then(|p| p.preferred_model.clone())
309 .or_else(|| settings.effective_model(None))
310 .unwrap_or_default();
311 let skills_dir = SkillManager::skills_read_dir().unwrap_or_else(|_| {
315 oxicode_catalog::product_env::home_dir()
316 .unwrap_or_default()
317 .join("skills")
318 });
319 let skills = SkillManager::load_from_dir(&skills_dir).unwrap_or_else(|e| {
320 tracing::debug!("Skills not loaded: {}", e);
321 SkillManager::new()
322 });
323
324 let body_str = persona.as_ref().map(|p| p.system_prompt.clone());
325 let system_prompt = build_system_prompt(settings.thinking_level, &[], body_str.as_deref());
326 let system_prompt = behavior
328 .as_ref()
329 .map(|b| {
330 let base = system_prompt.as_str();
331 let layers = b
332 .patch
333 .prompt_layers
334 .iter()
335 .map(|l| l.body.clone())
336 .collect::<Vec<_>>()
337 .join("\n\n");
338 if layers.is_empty() {
339 base.to_string()
340 } else {
341 format!("{layers}\n\n{base}")
342 }
343 })
344 .unwrap_or(system_prompt);
345 let compaction_strategy = if settings.auto_compaction {
346 oxicode_sdk::CompactionStrategy::Threshold(0.8)
347 } else {
348 oxicode_sdk::CompactionStrategy::Disabled
349 };
350
351 let config = AgentConfig {
352 name: "oxicode".to_string(),
353 description: Some("oxicode CLI agent".to_string()),
354 model_id: model_id.clone(),
355 system_prompt: Some(system_prompt),
356 snapshot_store: behavior
357 .as_ref()
358 .and_then(|b| b.patch.snapshot_store.clone()),
359 timeout_seconds: settings.tool_timeout_seconds,
360 temperature: settings.effective_temperature(),
361 max_tokens: settings.effective_max_tokens(),
362 compaction_strategy,
363 compaction_instruction: None,
364 context_window: 128_000,
365 workspace_dir: Some(
366 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
367 ),
368 output_mode: None,
369 provider_options: None,
370 session_id: Some(ownership_session_id.clone()),
371 ttsr_engine: None,
372 memory: None,
373 todo: None,
374 agent_pool: None,
375 url_resolver: Some(Arc::new(oxicode_sdk::SdkUrlResolver::new(
376 oxicode.ports().url_router.clone(),
377 ))),
378 lsp: if crate::lsp::manager::default_servers().is_empty() {
384 None
385 } else {
386 Some(Arc::new(crate::lsp::CliLspProvider::with_defaults(
387 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
388 )))
389 },
390 ..Default::default()
391 };
392
393 let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
402
403 let ask_timeout = if settings.ask_timeout_secs > 0 {
412 Some(std::time::Duration::from_secs(settings.ask_timeout_secs))
413 } else {
414 None
415 };
416 let bridge = std::sync::Arc::new(oxicode_agent::tools::ask::AskBridge::with_timeout(
417 ask_timeout,
418 ));
419 let mode_handle = bridge.mode_handle();
420
421 let stop_flag = Arc::clone(&session_state.should_stop);
422 let steering = Arc::clone(&session_state.steering);
423 let follow_up = Arc::clone(&session_state.follow_up);
424 let session_hooks = oxicode_sdk::agent_builder::SessionHookClosures {
425 should_stop_after_turn: Arc::new(move |_| {
426 stop_flag.load(std::sync::atomic::Ordering::SeqCst)
427 }),
428 get_steering_messages: Arc::new(move || {
429 let mut msgs: Vec<oxicode_sdk::Message> = steering.write().drain(..).collect();
430 if oxicode_agent::config::Mode::load(&mode_handle).is_auto() {
433 msgs.push(oxicode_sdk::Message::User(oxicode_sdk::UserMessage::new(
434 "Autonomy mode (auto) is active: proceed autonomously to \
435 completion without asking the user questions. Make \
436 reasonable decisions on your own and keep working.",
437 )));
438 }
439 msgs
440 }),
441 get_follow_up_messages: Arc::new(move || follow_up.write().drain(..).collect()),
442 tool_execution: oxicode_agent::config::ToolExecutionMode::Sequential,
443 };
444
445 let agent = oxicode
446 .agent(config)
447 .workspace(cwd)
448 .with_port_hooks()
449 .with_session_hooks(session_hooks)
450 .build()
451 .map_err(|e| Error::msg(format!("agent build failed: {e}")))?;
452 let agent = Arc::new(agent);
453
454 let ask_tool = oxicode_agent::tools::ask::AskTool::new(bridge.clone());
455 agent.tools().register_arc(std::sync::Arc::new(ask_tool));
456 let issue_store = std::env::current_dir()
461 .ok()
462 .map(|cwd| oxicode_sdk::FileIssueStore::open_from_cwd(&cwd))
463 .and_then(|r| {
464 r.map_err(|e| tracing::warn!("issue store unavailable: {e}"))
465 .ok()
466 });
467
468 if let Some(store) = issue_store.clone() {
470 let tool = std::sync::Arc::new(oxicode_sdk::IssueTool::new(store));
471 agent.tools().register_arc(tool);
472 }
473
474 Ok(Self {
475 oxicode,
476 agent,
477 settings,
478 skills: RwLock::new(skills),
479 active_skills: RwLock::new(Vec::new()),
480 wasm_ext: None,
481 ask_bridge: Some(bridge),
482 issue_store,
483 ownership_session_id,
484 liveness_guard: None, persona_body: RwLock::new(persona.as_ref().map(|p| p.system_prompt.clone())),
486 session_state,
487 behavior,
488 })
489 .map(|mut app| {
490 app.liveness_guard =
494 acquire_ownership_guard(app.issue_store.as_ref(), &app.ownership_session_id);
495 app
496 })
497 }
498
499 pub fn ownership_session_id(&self) -> &str {
502 &self.ownership_session_id
503 }
504
505 pub fn has_liveness_lock(&self) -> bool {
510 self.liveness_guard.is_some()
511 }
512
513 pub fn settings(&self) -> &Settings {
515 &self.settings
516 }
517
518 pub fn set_wasm_ext(
520 &mut self,
521 ext: Option<std::sync::Arc<crate::extensions::WasmExtensionManager>>,
522 ) {
523 self.wasm_ext = ext;
524 }
525
526 pub fn wasm_ext(&self) -> Option<&std::sync::Arc<crate::extensions::WasmExtensionManager>> {
528 self.wasm_ext.as_ref()
529 }
530
531 pub fn issue_store(&self) -> Option<oxicode_sdk::FileIssueStore> {
533 self.issue_store.clone()
534 }
535
536 pub fn oxicode(&self) -> &oxicode_sdk::Oxicode {
539 &self.oxicode
540 }
541
542 pub fn catalog(&self) -> std::sync::Arc<dyn oxicode_sdk::ports::catalog::ModelCatalog> {
545 std::sync::Arc::clone(self.oxicode.catalog())
546 }
547
548 pub fn agent(&self) -> Arc<Agent> {
550 Arc::clone(&self.agent)
551 }
552
553 pub fn agent_tools(&self) -> Arc<oxicode_agent::ToolRegistry> {
555 self.agent.tools()
556 }
557
558 pub fn ask_bridge(&self) -> Option<&std::sync::Arc<oxicode_agent::tools::ask::AskBridge>> {
560 self.ask_bridge.as_ref()
561 }
562
563 pub fn skills(&self) -> parking_lot::RwLockReadGuard<'_, SkillManager> {
565 self.skills.read()
566 }
567
568 pub fn activate_skill(&self, name: &str) -> Result<(), String> {
570 {
571 let skills = self.skills.read();
572 if skills.get(name).is_none() {
573 return Err(format!("Skill '{}' not found", name));
574 }
575 }
576 let name_lower = name.to_lowercase();
577 {
578 let mut active = self.active_skills.write();
579 if !active.contains(&name_lower) {
580 active.push(name_lower);
581 }
582 }
583 self.rebuild_system_prompt();
584 Ok(())
585 }
586
587 pub fn deactivate_skill(&self, name: &str) {
589 let name_lower = name.to_lowercase();
590 {
591 let mut active = self.active_skills.write();
592 active.retain(|n| n != &name_lower);
593 }
594 self.rebuild_system_prompt();
595 }
596
597 pub fn active_skills(&self) -> Vec<String> {
599 self.active_skills.read().clone()
600 }
601
602 fn rebuild_system_prompt(&self) {
604 let active = self.active_skills.read();
605 let skills = self.skills.read();
606 let contents: Vec<String> = active
607 .iter()
608 .filter_map(|name| skills.get(name).map(|s| s.content.clone()))
609 .collect();
610 let persona = self.persona_body.read().clone();
614 let prompt =
615 build_system_prompt(self.settings.thinking_level, &contents, persona.as_deref());
616 self.agent.set_system_prompt(prompt);
617 }
618
619 pub fn agent_state(&self) -> oxicode_agent::AgentState {
621 self.agent.state()
622 }
623
624 pub async fn run_prompt(&self, prompt: String) -> Result<String> {
626 let (response, _events) = self.agent.run(prompt).await?;
627 Ok(response.content)
628 }
629
630 pub async fn run_prompt_with_events<F>(&self, prompt: String, on_event: F) -> Result<String>
632 where
633 F: FnMut(AgentEvent) + Send + 'static,
634 {
635 self.agent.run_streaming(prompt, on_event).await?;
636 let state = self.agent_state();
637 for msg in state.messages.iter().rev() {
638 if let oxicode_sdk::Message::Assistant(a) = msg {
639 return Ok(a.text_content());
640 }
641 }
642 Ok(String::new())
643 }
644
645 pub fn reset(&self) {
647 self.agent.reset();
648 }
649
650 pub async fn switch_model(&self, model_id: &str) -> anyhow::Result<()> {
656 let _ = self.agent.switch_model(model_id);
657 Ok(())
658 }
659
660 pub fn model_id(&self) -> String {
662 self.agent.model_id()
663 }
664
665 pub fn session_state(&self) -> &SessionState {
670 &self.session_state
671 }
672
673 pub fn should_stop_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
675 Arc::clone(&self.session_state.should_stop)
676 }
677
678 pub fn steering_queue(&self) -> Arc<RwLock<VecDeque<oxicode_sdk::Message>>> {
680 Arc::clone(&self.session_state.steering)
681 }
682
683 pub fn follow_up_queue(&self) -> Arc<RwLock<VecDeque<oxicode_sdk::Message>>> {
685 Arc::clone(&self.session_state.follow_up)
686 }
687}
688
689pub(crate) fn acquire_ownership_guard(
700 issue_store: Option<&oxicode_sdk::FileIssueStore>,
701 ownership_id: &str,
702) -> Option<oxicode_sdk::liveness::AliveGuard> {
703 let store = issue_store?;
704 if ownership_id.is_empty() {
705 return None;
708 }
709 match oxicode_sdk::liveness::acquire(&store.issues_dir(), ownership_id) {
710 Ok(guard) => Some(guard),
711 Err(e) => {
712 tracing::warn!(
717 ownership_id,
718 error = %e,
719 "issue liveness flock acquisition failed; this session's \
720 ownership claims are contestable while the lock is missing"
721 );
722 None
723 }
724 }
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
734 use oxicode_sdk::FileIssueStore;
735 use oxicode_sdk::liveness;
736
737 fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
738 let tmp = tempfile::tempdir().unwrap();
739 let dir = tmp.path().join(".oxicode").join("issues");
740 std::fs::create_dir_all(&dir).unwrap();
741 (tmp, FileIssueStore::open(dir).unwrap())
742 }
743
744 #[test]
745 fn app_holds_single_liveness_lock() {
746 let (_tmp, store) = tmp_store();
750 let dir = store.issues_dir();
751 let id = "proc-test-app";
752
753 let guard = acquire_ownership_guard(Some(&store), id);
754 assert!(
755 guard.is_some(),
756 "App must acquire the liveness lock for its ownership id"
757 );
758 assert!(
759 liveness::is_session_alive(&dir, id),
760 "after acquire, the session must be live"
761 );
762
763 let second = liveness::acquire(&dir, id);
765 assert!(second.is_err(), "second acquire under same id must fail");
766
767 drop(guard);
768 assert!(
769 !liveness::is_session_alive(&dir, id),
770 "dropping App's guard releases the lock"
771 );
772 }
773
774 #[test]
775 fn acquire_returns_none_without_store() {
776 let dir = tempfile::tempdir().unwrap();
778 let id = "proc-x";
779 assert!(acquire_ownership_guard(None, id).is_none());
780 let _ = dir; }
782
783 #[test]
784 fn acquire_rejects_empty_ownership_id() {
785 let (_tmp, store) = tmp_store();
788 assert!(
789 acquire_ownership_guard(Some(&store), "").is_none(),
790 "empty ownership id must never acquire a lock (#13 guard)"
791 );
792 }
793}
794pub mod symbols;
795pub mod tui_vt;