#![deny(clippy::unwrap_used, clippy::expect_used)]
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
#[cfg(feature = "sqlite-store")]
use kernex_core::config::MemoryConfig;
use kernex_core::context::ContextNeeds;
use kernex_core::error::KernexError;
use kernex_core::hooks::{HookRunner, NoopHookRunner};
use kernex_core::message::{Request, Response};
use kernex_core::permissions::PermissionRules;
use kernex_core::run::{RunConfig, RunOutcome};
use kernex_core::traits::Provider;
#[cfg(feature = "sqlite-store")]
use kernex_memory::Store;
use kernex_skills::{
build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
};
use std::sync::Arc;
pub use kernex_core as core;
#[cfg(feature = "sqlite-store")]
pub use kernex_memory as memory;
pub use kernex_pipelines as pipelines;
pub use kernex_providers as providers;
pub use kernex_sandbox as sandbox;
pub use kernex_skills as skills;
pub struct Runtime {
#[cfg(feature = "sqlite-store")]
pub store: Store,
pub skills: Vec<Skill>,
pub projects: Vec<Project>,
pub data_dir: String,
pub system_prompt: String,
pub channel: String,
pub project: Option<String>,
pub hook_runner: Arc<dyn HookRunner>,
pub permission_rules: Option<Arc<PermissionRules>>,
}
impl Runtime {
pub async fn complete(
&self,
provider: &dyn Provider,
request: &Request,
) -> Result<Response, KernexError> {
self.complete_with_needs(provider, request, &ContextNeeds::default())
.await
}
pub async fn complete_with_needs(
&self,
provider: &dyn Provider,
request: &Request,
#[allow(unused_variables)] needs: &ContextNeeds,
) -> Result<Response, KernexError> {
let project_ref = self.project.as_deref();
let skill_ctx = build_skill_prompt(&self.skills);
let full_system_prompt = if skill_ctx.prompt.is_empty() {
self.system_prompt.clone()
} else if self.system_prompt.is_empty() {
skill_ctx.prompt.clone()
} else {
format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
};
#[cfg(feature = "sqlite-store")]
let mut context = self
.store
.build_context(
&self.channel,
request,
&full_system_prompt,
needs,
project_ref,
None,
)
.await?;
#[cfg(not(feature = "sqlite-store"))]
let mut context = {
let mut ctx = kernex_core::context::Context::new(&request.text);
ctx.system_prompt = full_system_prompt;
ctx
};
if context.model.is_none() {
context.model = skill_ctx.model;
}
let mcp_servers = match_skill_triggers(&self.skills, &request.text);
if !mcp_servers.is_empty() {
context.mcp_servers = mcp_servers;
}
let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
if !toolboxes.is_empty() {
context.toolboxes = toolboxes;
}
context.hook_runner = Some(self.hook_runner.clone());
context.permission_rules = self.permission_rules.clone();
let response = provider.complete(&context).await?;
#[allow(unused_variables)]
let project_key = project_ref.unwrap_or("default");
#[cfg(feature = "sqlite-store")]
self.store
.store_exchange(&self.channel, request, &response, project_key)
.await?;
#[cfg(feature = "sqlite-store")]
if let Some(tokens) = response.metadata.tokens_used {
let model = response.metadata.model.as_deref().unwrap_or("unknown");
let session = response.metadata.session_id.as_deref().unwrap_or("default");
if let Err(e) = self
.store
.record_usage(&request.sender_id, session, tokens, model)
.await
{
tracing::warn!("failed to record token usage: {e}");
}
}
Ok(response)
}
pub async fn run(
&self,
provider: &dyn Provider,
request: &Request,
config: &RunConfig,
) -> Result<RunOutcome, KernexError> {
let needs = ContextNeeds::default();
let project_ref = self.project.as_deref();
let skill_ctx = build_skill_prompt(&self.skills);
let full_system_prompt = if skill_ctx.prompt.is_empty() {
self.system_prompt.clone()
} else if self.system_prompt.is_empty() {
skill_ctx.prompt.clone()
} else {
format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
};
#[cfg(feature = "sqlite-store")]
let mut context = self
.store
.build_context(
&self.channel,
request,
&full_system_prompt,
&needs,
project_ref,
None,
)
.await?;
#[cfg(not(feature = "sqlite-store"))]
let mut context = {
let mut ctx = kernex_core::context::Context::new(&request.text);
ctx.system_prompt = full_system_prompt;
ctx
};
if context.model.is_none() {
context.model = skill_ctx.model;
}
let mcp_servers = match_skill_triggers(&self.skills, &request.text);
if !mcp_servers.is_empty() {
context.mcp_servers = mcp_servers;
}
let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
if !toolboxes.is_empty() {
context.toolboxes = toolboxes;
}
context.max_turns = Some(config.max_turns);
context.hook_runner = Some(self.hook_runner.clone());
context.permission_rules = self.permission_rules.clone();
let response = provider.complete(&context).await?;
self.hook_runner.on_stop(&response.text).await;
#[allow(unused_variables)]
let project_key = project_ref.unwrap_or("default");
#[cfg(feature = "sqlite-store")]
self.store
.store_exchange(&self.channel, request, &response, project_key)
.await?;
#[cfg(feature = "sqlite-store")]
if let Some(tokens) = response.metadata.tokens_used {
let model = response.metadata.model.as_deref().unwrap_or("unknown");
let session = response.metadata.session_id.as_deref().unwrap_or("default");
if let Err(e) = self
.store
.record_usage(&request.sender_id, session, tokens, model)
.await
{
tracing::warn!("failed to record token usage: {e}");
}
}
Ok(RunOutcome::EndTurn(response))
}
}
pub struct RuntimeBuilder {
data_dir: String,
#[cfg(feature = "sqlite-store")]
db_path: Option<String>,
system_prompt: String,
channel: String,
project: Option<String>,
hook_runner: Option<Arc<dyn HookRunner>>,
permission_rules: Option<Arc<PermissionRules>>,
}
impl RuntimeBuilder {
pub fn new() -> Self {
Self {
data_dir: "~/.kernex".to_string(),
#[cfg(feature = "sqlite-store")]
db_path: None,
system_prompt: String::new(),
channel: "cli".to_string(),
project: None,
hook_runner: None,
permission_rules: None,
}
}
pub fn from_env() -> Self {
let mut builder = Self::new();
if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
builder = builder.data_dir(&dir);
}
#[cfg(feature = "sqlite-store")]
if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
builder = builder.db_path(&path);
}
if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
builder = builder.system_prompt(&prompt);
}
if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
builder = builder.channel(&channel);
}
if let Ok(project) = std::env::var("KERNEX_PROJECT") {
builder = builder.project(&project);
}
builder
}
pub fn data_dir(mut self, path: &str) -> Self {
self.data_dir = path.to_string();
self
}
#[cfg(feature = "sqlite-store")]
pub fn db_path(mut self, path: &str) -> Self {
self.db_path = Some(path.to_string());
self
}
pub fn system_prompt(mut self, prompt: &str) -> Self {
self.system_prompt = prompt.to_string();
self
}
pub fn channel(mut self, channel: &str) -> Self {
self.channel = channel.to_string();
self
}
pub fn project(mut self, project: &str) -> Self {
self.project = Some(project.to_string());
self
}
pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
self.hook_runner = Some(runner);
self
}
pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
self.permission_rules = Some(Arc::new(rules));
self
}
pub async fn build(self) -> Result<Runtime, KernexError> {
let expanded_dir = kernex_core::shellexpand(&self.data_dir);
tokio::fs::create_dir_all(&expanded_dir)
.await
.map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
#[cfg(feature = "sqlite-store")]
let store = {
let db_path = self
.db_path
.unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
let mem_config = MemoryConfig {
db_path: db_path.clone(),
..Default::default()
};
Store::new(&mem_config).await?
};
let skills = kernex_skills::load_skills(&self.data_dir);
let projects = kernex_skills::load_projects(&self.data_dir);
tracing::info!(
"runtime initialized: {} skills, {} projects",
skills.len(),
projects.len()
);
let hook_runner: Arc<dyn HookRunner> =
self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
Ok(Runtime {
#[cfg(feature = "sqlite-store")]
store,
skills,
projects,
data_dir: expanded_dir,
system_prompt: self.system_prompt,
channel: self.channel,
project: self.project,
hook_runner,
permission_rules: self.permission_rules,
})
}
}
impl Default for RuntimeBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_runtime_builder_creates_runtime() {
let tmp = std::env::temp_dir().join("__kernex_test_runtime__");
let _ = std::fs::remove_dir_all(&tmp);
let runtime = RuntimeBuilder::new()
.data_dir(tmp.to_str().unwrap())
.build()
.await
.unwrap();
assert!(runtime.skills.is_empty());
assert!(runtime.projects.is_empty());
assert!(runtime.system_prompt.is_empty());
assert_eq!(runtime.channel, "cli");
assert!(runtime.project.is_none());
assert!(std::path::Path::new(&runtime.data_dir).exists());
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn test_runtime_builder_custom_db_path() {
let tmp = std::env::temp_dir().join("__kernex_test_runtime_db__");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let db = tmp.join("custom.db");
let runtime = RuntimeBuilder::new()
.data_dir(tmp.to_str().unwrap())
.db_path(db.to_str().unwrap())
.build()
.await
.unwrap();
assert!(db.exists());
drop(runtime);
let _ = std::fs::remove_dir_all(&tmp);
}
#[tokio::test]
async fn test_runtime_builder_with_config() {
let tmp = std::env::temp_dir().join("__kernex_test_runtime_cfg__");
let _ = std::fs::remove_dir_all(&tmp);
let runtime = RuntimeBuilder::new()
.data_dir(tmp.to_str().unwrap())
.system_prompt("You are helpful.")
.channel("api")
.project("my-project")
.build()
.await
.unwrap();
assert_eq!(runtime.system_prompt, "You are helpful.");
assert_eq!(runtime.channel, "api");
assert_eq!(runtime.project, Some("my-project".to_string()));
let _ = std::fs::remove_dir_all(&tmp);
}
}