1#![deny(clippy::unwrap_used, clippy::expect_used)]
3#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
4#[cfg(feature = "sqlite-store")]
39use kernex_core::config::MemoryConfig;
40use kernex_core::context::ContextNeeds;
41use kernex_core::error::KernexError;
42use kernex_core::hooks::{HookRunner, NoopHookRunner};
43use kernex_core::message::{Request, Response};
44use kernex_core::permissions::PermissionRules;
45use kernex_core::run::{RunConfig, RunOutcome};
46use kernex_core::traits::Provider;
47#[cfg(feature = "sqlite-store")]
48use kernex_memory::Store;
49use kernex_skills::{
50 build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
51};
52use std::sync::Arc;
53
54pub use kernex_core as core;
56#[cfg(feature = "sqlite-store")]
57pub use kernex_memory as memory;
58pub use kernex_pipelines as pipelines;
59pub use kernex_providers as providers;
60pub use kernex_sandbox as sandbox;
61pub use kernex_skills as skills;
62
63pub struct Runtime {
65 #[cfg(feature = "sqlite-store")]
67 pub store: Store,
68 pub skills: Vec<Skill>,
70 pub projects: Vec<Project>,
72 pub data_dir: String,
74 pub system_prompt: String,
76 pub channel: String,
78 pub project: Option<String>,
80 pub hook_runner: Arc<dyn HookRunner>,
82 pub permission_rules: Option<Arc<PermissionRules>>,
84}
85
86impl Runtime {
87 pub async fn complete(
93 &self,
94 provider: &dyn Provider,
95 request: &Request,
96 ) -> Result<Response, KernexError> {
97 self.complete_with_needs(provider, request, &ContextNeeds::default())
98 .await
99 }
100
101 pub async fn complete_with_needs(
104 &self,
105 provider: &dyn Provider,
106 request: &Request,
107 #[allow(unused_variables)] needs: &ContextNeeds,
108 ) -> Result<Response, KernexError> {
109 let project_ref = self.project.as_deref();
110
111 let skill_ctx = build_skill_prompt(&self.skills);
113 let full_system_prompt = if skill_ctx.prompt.is_empty() {
114 self.system_prompt.clone()
115 } else if self.system_prompt.is_empty() {
116 skill_ctx.prompt.clone()
117 } else {
118 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
119 };
120
121 #[cfg(feature = "sqlite-store")]
123 let mut context = self
124 .store
125 .build_context(
126 &self.channel,
127 request,
128 &full_system_prompt,
129 needs,
130 project_ref,
131 None,
132 )
133 .await?;
134
135 #[cfg(not(feature = "sqlite-store"))]
136 let mut context = {
137 let mut ctx = kernex_core::context::Context::new(&request.text);
138 ctx.system_prompt = full_system_prompt;
139 ctx
140 };
141
142 if context.model.is_none() {
144 context.model = skill_ctx.model;
145 }
146
147 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
149 if !mcp_servers.is_empty() {
150 context.mcp_servers = mcp_servers;
151 }
152
153 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
155 if !toolboxes.is_empty() {
156 context.toolboxes = toolboxes;
157 }
158
159 context.hook_runner = Some(self.hook_runner.clone());
161 context.permission_rules = self.permission_rules.clone();
162
163 let response = provider.complete(&context).await?;
165
166 #[allow(unused_variables)]
168 let project_key = project_ref.unwrap_or("default");
169
170 #[cfg(feature = "sqlite-store")]
171 self.store
172 .store_exchange(&self.channel, request, &response, project_key)
173 .await?;
174
175 #[cfg(feature = "sqlite-store")]
177 if let Some(tokens) = response.metadata.tokens_used {
178 let model = response.metadata.model.as_deref().unwrap_or("unknown");
179 let session = response.metadata.session_id.as_deref().unwrap_or("default");
180 if let Err(e) = self
181 .store
182 .record_usage(&request.sender_id, session, tokens, model)
183 .await
184 {
185 tracing::warn!("failed to record token usage: {e}");
186 }
187 }
188
189 Ok(response)
190 }
191
192 pub async fn run(
198 &self,
199 provider: &dyn Provider,
200 request: &Request,
201 config: &RunConfig,
202 ) -> Result<RunOutcome, KernexError> {
203 let needs = ContextNeeds::default();
204 let project_ref = self.project.as_deref();
205
206 let skill_ctx = build_skill_prompt(&self.skills);
207 let full_system_prompt = if skill_ctx.prompt.is_empty() {
208 self.system_prompt.clone()
209 } else if self.system_prompt.is_empty() {
210 skill_ctx.prompt.clone()
211 } else {
212 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
213 };
214
215 #[cfg(feature = "sqlite-store")]
216 let mut context = self
217 .store
218 .build_context(
219 &self.channel,
220 request,
221 &full_system_prompt,
222 &needs,
223 project_ref,
224 None,
225 )
226 .await?;
227
228 #[cfg(not(feature = "sqlite-store"))]
229 let mut context = {
230 let mut ctx = kernex_core::context::Context::new(&request.text);
231 ctx.system_prompt = full_system_prompt;
232 ctx
233 };
234
235 if context.model.is_none() {
237 context.model = skill_ctx.model;
238 }
239
240 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
241 if !mcp_servers.is_empty() {
242 context.mcp_servers = mcp_servers;
243 }
244 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
245 if !toolboxes.is_empty() {
246 context.toolboxes = toolboxes;
247 }
248
249 context.max_turns = Some(config.max_turns);
251 context.hook_runner = Some(self.hook_runner.clone());
252 context.permission_rules = self.permission_rules.clone();
253
254 let response = provider.complete(&context).await?;
255
256 self.hook_runner.on_stop(&response.text).await;
258
259 #[allow(unused_variables)]
261 let project_key = project_ref.unwrap_or("default");
262 #[cfg(feature = "sqlite-store")]
263 self.store
264 .store_exchange(&self.channel, request, &response, project_key)
265 .await?;
266
267 #[cfg(feature = "sqlite-store")]
269 if let Some(tokens) = response.metadata.tokens_used {
270 let model = response.metadata.model.as_deref().unwrap_or("unknown");
271 let session = response.metadata.session_id.as_deref().unwrap_or("default");
272 if let Err(e) = self
273 .store
274 .record_usage(&request.sender_id, session, tokens, model)
275 .await
276 {
277 tracing::warn!("failed to record token usage: {e}");
278 }
279 }
280
281 Ok(RunOutcome::EndTurn(response))
282 }
283}
284
285pub struct RuntimeBuilder {
287 data_dir: String,
288 #[cfg(feature = "sqlite-store")]
289 db_path: Option<String>,
290 system_prompt: String,
291 channel: String,
292 project: Option<String>,
293 hook_runner: Option<Arc<dyn HookRunner>>,
294 permission_rules: Option<Arc<PermissionRules>>,
295}
296
297impl RuntimeBuilder {
298 pub fn new() -> Self {
300 Self {
301 data_dir: "~/.kernex".to_string(),
302 #[cfg(feature = "sqlite-store")]
303 db_path: None,
304 system_prompt: String::new(),
305 channel: "cli".to_string(),
306 project: None,
307 hook_runner: None,
308 permission_rules: None,
309 }
310 }
311
312 pub fn from_env() -> Self {
321 let mut builder = Self::new();
322
323 if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
324 builder = builder.data_dir(&dir);
325 }
326 #[cfg(feature = "sqlite-store")]
327 if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
328 builder = builder.db_path(&path);
329 }
330 if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
331 builder = builder.system_prompt(&prompt);
332 }
333 if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
334 builder = builder.channel(&channel);
335 }
336 if let Ok(project) = std::env::var("KERNEX_PROJECT") {
337 builder = builder.project(&project);
338 }
339
340 builder
341 }
342
343 pub fn data_dir(mut self, path: &str) -> Self {
345 self.data_dir = path.to_string();
346 self
347 }
348
349 #[cfg(feature = "sqlite-store")]
351 pub fn db_path(mut self, path: &str) -> Self {
352 self.db_path = Some(path.to_string());
353 self
354 }
355
356 pub fn system_prompt(mut self, prompt: &str) -> Self {
358 self.system_prompt = prompt.to_string();
359 self
360 }
361
362 pub fn channel(mut self, channel: &str) -> Self {
364 self.channel = channel.to_string();
365 self
366 }
367
368 pub fn project(mut self, project: &str) -> Self {
370 self.project = Some(project.to_string());
371 self
372 }
373
374 pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
376 self.hook_runner = Some(runner);
377 self
378 }
379
380 pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
382 self.permission_rules = Some(Arc::new(rules));
383 self
384 }
385
386 pub async fn build(self) -> Result<Runtime, KernexError> {
388 let expanded_dir = kernex_core::shellexpand(&self.data_dir);
389
390 tokio::fs::create_dir_all(&expanded_dir)
392 .await
393 .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
394
395 #[cfg(feature = "sqlite-store")]
397 let store = {
398 let db_path = self
399 .db_path
400 .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
401 let mem_config = MemoryConfig {
402 db_path: db_path.clone(),
403 ..Default::default()
404 };
405 Store::new(&mem_config).await?
406 };
407
408 let skills = kernex_skills::load_skills(&self.data_dir);
410 let projects = kernex_skills::load_projects(&self.data_dir);
411
412 tracing::info!(
413 "runtime initialized: {} skills, {} projects",
414 skills.len(),
415 projects.len()
416 );
417
418 let hook_runner: Arc<dyn HookRunner> =
419 self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
420
421 Ok(Runtime {
422 #[cfg(feature = "sqlite-store")]
423 store,
424 skills,
425 projects,
426 data_dir: expanded_dir,
427 system_prompt: self.system_prompt,
428 channel: self.channel,
429 project: self.project,
430 hook_runner,
431 permission_rules: self.permission_rules,
432 })
433 }
434}
435
436impl Default for RuntimeBuilder {
437 fn default() -> Self {
438 Self::new()
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[tokio::test]
447 async fn test_runtime_builder_creates_runtime() {
448 let tmp = std::env::temp_dir().join("__kernex_test_runtime__");
449 let _ = std::fs::remove_dir_all(&tmp);
450
451 let runtime = RuntimeBuilder::new()
452 .data_dir(tmp.to_str().unwrap())
453 .build()
454 .await
455 .unwrap();
456
457 assert!(runtime.skills.is_empty());
458 assert!(runtime.projects.is_empty());
459 assert!(runtime.system_prompt.is_empty());
460 assert_eq!(runtime.channel, "cli");
461 assert!(runtime.project.is_none());
462 assert!(std::path::Path::new(&runtime.data_dir).exists());
463
464 let _ = std::fs::remove_dir_all(&tmp);
465 }
466
467 #[tokio::test]
468 async fn test_runtime_builder_custom_db_path() {
469 let tmp = std::env::temp_dir().join("__kernex_test_runtime_db__");
470 let _ = std::fs::remove_dir_all(&tmp);
471 std::fs::create_dir_all(&tmp).unwrap();
472
473 let db = tmp.join("custom.db");
474 let runtime = RuntimeBuilder::new()
475 .data_dir(tmp.to_str().unwrap())
476 .db_path(db.to_str().unwrap())
477 .build()
478 .await
479 .unwrap();
480
481 assert!(db.exists());
482 drop(runtime);
483 let _ = std::fs::remove_dir_all(&tmp);
484 }
485
486 #[tokio::test]
487 async fn test_runtime_builder_with_config() {
488 let tmp = std::env::temp_dir().join("__kernex_test_runtime_cfg__");
489 let _ = std::fs::remove_dir_all(&tmp);
490
491 let runtime = RuntimeBuilder::new()
492 .data_dir(tmp.to_str().unwrap())
493 .system_prompt("You are helpful.")
494 .channel("api")
495 .project("my-project")
496 .build()
497 .await
498 .unwrap();
499
500 assert_eq!(runtime.system_prompt, "You are helpful.");
501 assert_eq!(runtime.channel, "api");
502 assert_eq!(runtime.project, Some("my-project".to_string()));
503
504 let _ = std::fs::remove_dir_all(&tmp);
505 }
506}