1use crate::cognitive_memory::WorkingNote;
12use crate::cognitive_signal::CognitiveSignal;
13use crate::error::PeError;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::future::Future;
17use std::path::PathBuf;
18use std::pin::Pin;
19use std::sync::Arc;
20use std::time::Duration;
21
22pub trait Lobe: Send + Sync {
50 fn name(&self) -> &str;
52
53 fn should_activate(&self, context: &LobeContext) -> bool;
57
58 fn priority(&self) -> u32;
61
62 fn budget(&self) -> LobeBudget;
64
65 fn output_format(&self) -> LobeOutputFormat;
67
68 fn process(&self, input: &LobeInput) -> LobeFuture;
70}
71
72pub type LobeFuture = Pin<Box<dyn Future<Output = Result<LobeOutput, PeError>> + Send>>;
74
75#[derive(Clone)]
80pub struct LobeInput {
81 pub input: String,
83
84 pub context: LobeContext,
86
87 pub notes: Vec<WorkingNote>,
89
90 pub runtime_services: Option<Arc<dyn LobeRuntimeServices>>,
96}
97
98impl std::fmt::Debug for LobeInput {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.debug_struct("LobeInput")
101 .field("input", &self.input)
102 .field("context", &self.context)
103 .field("notes", &self.notes)
104 .field("has_runtime_services", &self.runtime_services.is_some())
105 .finish()
106 }
107}
108
109#[derive(Debug, Clone, Default)]
114pub struct LobeContext {
115 pub self_summary: Option<String>,
117
118 pub recent_errors: Vec<String>,
120
121 pub confidence: f64,
123
124 pub current_plan: Option<String>,
126
127 pub metadata: HashMap<String, serde_json::Value>,
129}
130
131impl LobeContext {
132 pub fn from_cognitive_state(state: &crate::cognitive::CognitiveState) -> Self {
137 let mut metadata = HashMap::new();
138 metadata.insert(
139 "working_notes_count".into(),
140 serde_json::Value::from(state.working_notes.len()),
141 );
142 metadata.insert(
143 "failure_records_count".into(),
144 serde_json::Value::from(state.failure_records.len()),
145 );
146 Self {
147 self_summary: None, recent_errors: state.error_history.clone(),
149 confidence: state.confidence,
150 current_plan: state.current_plan.clone(),
151 metadata,
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
158pub struct LobeOutput {
159 #[serde(default)]
161 pub lobe_name: String,
162
163 pub content: String,
165
166 pub confidence: f64,
168
169 #[serde(default)]
171 pub signals: Vec<CognitiveSignal>,
172
173 #[serde(default)]
175 pub metadata: HashMap<String, serde_json::Value>,
176}
177
178impl LobeOutput {
179 pub fn new(content: impl Into<String>, confidence: f64) -> Self {
181 Self {
182 lobe_name: String::new(),
183 content: content.into(),
184 confidence,
185 signals: Vec::new(),
186 metadata: HashMap::new(),
187 }
188 }
189
190 #[must_use]
192 pub fn with_lobe_name(mut self, name: impl Into<String>) -> Self {
193 self.lobe_name = name.into();
194 self
195 }
196
197 #[must_use]
199 pub fn with_signal(mut self, signal: CognitiveSignal) -> Self {
200 self.signals.push(signal);
201 self
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct LobeBudget {
208 pub max_tokens: u32,
210
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub max_duration: Option<Duration>,
214
215 #[serde(default)]
222 pub streaming: bool,
223}
224
225impl Default for LobeBudget {
226 fn default() -> Self {
227 Self {
228 max_tokens: 500,
229 max_duration: Some(Duration::from_secs(5)),
230 streaming: false,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
239#[non_exhaustive]
240pub enum LobeOutputFormat {
241 FreeText,
243 Structured,
245 Score,
247 Boolean,
249 Custom(String),
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259#[non_exhaustive]
260pub enum LobeActivation {
261 AlwaysOn,
263 OnDemand,
265 Conditional,
267}
268
269pub trait LobeRuntimeServices: Send + Sync {
274 fn inspect(&self, request: LobeInspectionRequest) -> Result<LobeInspectionResult, PeError>;
276}
277
278pub trait LobeRuntimeServiceFactory: Send + Sync {
280 fn for_lobe(&self, lobe_name: &str) -> Arc<dyn LobeRuntimeServices>;
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct LobeInspectionRequest {
287 pub root: PathBuf,
288 pub allowed_roots: Vec<PathBuf>,
289 pub max_files: Option<u64>,
290 pub max_bytes: Option<u64>,
291 pub max_depth: Option<usize>,
292 pub include_contents: bool,
293 pub include_extensions: Vec<String>,
294 pub exclude_names: Vec<String>,
295 pub exclude_path_prefixes: Vec<PathBuf>,
296 pub max_preview_bytes_per_file: Option<u64>,
297 pub skip_hidden: bool,
298}
299
300impl LobeInspectionRequest {
301 pub fn new(root: impl Into<PathBuf>) -> Self {
303 Self {
304 root: root.into(),
305 allowed_roots: Vec::new(),
306 max_files: None,
307 max_bytes: None,
308 max_depth: None,
309 include_contents: false,
310 include_extensions: Vec::new(),
311 exclude_names: Vec::new(),
312 exclude_path_prefixes: Vec::new(),
313 max_preview_bytes_per_file: None,
314 skip_hidden: false,
315 }
316 }
317
318 #[must_use = "builder methods return the modified builder"]
319 pub fn with_allowed_roots(mut self, roots: impl IntoIterator<Item = PathBuf>) -> Self {
320 self.allowed_roots = roots.into_iter().collect();
321 self
322 }
323
324 #[must_use = "builder methods return the modified builder"]
325 pub fn with_contents(mut self, include_contents: bool) -> Self {
326 self.include_contents = include_contents;
327 self
328 }
329
330 #[must_use = "builder methods return the modified builder"]
331 pub fn with_extensions<I, S>(mut self, extensions: I) -> Self
332 where
333 I: IntoIterator<Item = S>,
334 S: AsRef<str>,
335 {
336 self.include_extensions = extensions
337 .into_iter()
338 .map(|ext| ext.as_ref().trim_start_matches('.').to_ascii_lowercase())
339 .filter(|ext| !ext.is_empty())
340 .collect();
341 self
342 }
343
344 #[must_use = "builder methods return the modified builder"]
345 pub fn with_excluded_names<I, S>(mut self, names: I) -> Self
346 where
347 I: IntoIterator<Item = S>,
348 S: AsRef<str>,
349 {
350 self.exclude_names = names
351 .into_iter()
352 .map(|name| name.as_ref().trim().to_string())
353 .filter(|name| !name.is_empty())
354 .collect();
355 self
356 }
357
358 #[must_use = "builder methods return the modified builder"]
359 pub fn with_excluded_path_prefixes<I, P>(mut self, prefixes: I) -> Self
360 where
361 I: IntoIterator<Item = P>,
362 P: Into<PathBuf>,
363 {
364 self.exclude_path_prefixes = prefixes.into_iter().map(Into::into).collect();
365 self
366 }
367
368 #[must_use = "builder methods return the modified builder"]
369 pub fn with_max_preview_bytes_per_file(mut self, max_bytes: u64) -> Self {
370 self.max_preview_bytes_per_file = Some(max_bytes);
371 self
372 }
373
374 #[must_use = "builder methods return the modified builder"]
375 pub fn with_skip_hidden(mut self, skip_hidden: bool) -> Self {
376 self.skip_hidden = skip_hidden;
377 self
378 }
379
380 #[must_use = "builder methods return the modified builder"]
381 pub fn with_max_files(mut self, max_files: u64) -> Self {
382 self.max_files = Some(max_files);
383 self
384 }
385
386 #[must_use = "builder methods return the modified builder"]
387 pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
388 self.max_bytes = Some(max_bytes);
389 self
390 }
391
392 #[must_use = "builder methods return the modified builder"]
393 pub fn with_max_depth(mut self, max_depth: usize) -> Self {
394 self.max_depth = Some(max_depth);
395 self
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct LobeInspectionEntry {
401 pub path: PathBuf,
402 pub kind: LobeInspectionEntryKind,
403 pub depth: usize,
404 pub size_bytes: Option<u64>,
405 pub content_preview: Option<String>,
406 pub content_truncated: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410pub enum LobeInspectionEntryKind {
411 Directory,
412 File,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
416pub struct LobeInspectionResult {
417 pub root: PathBuf,
418 pub max_files: Option<u64>,
419 pub max_bytes: Option<u64>,
420 pub max_depth: Option<usize>,
421 pub entries: Vec<LobeInspectionEntry>,
422 pub files_seen: u64,
423 pub bytes_read: u64,
424 pub truncated: bool,
425 pub truncation_reason: Option<String>,
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use crate::cognitive_signal::CognitiveSignal;
432
433 #[test]
434 fn test_lobe_output_creation() {
435 let output = LobeOutput::new("analysis result", 0.92);
436 assert_eq!(output.content, "analysis result");
437 assert!((output.confidence - 0.92).abs() < f64::EPSILON);
438 assert!(output.signals.is_empty());
439 }
440
441 #[test]
442 fn test_lobe_output_with_signal() {
443 let output =
444 LobeOutput::new("risky", 0.3).with_signal(CognitiveSignal::ProceedWithCaution {
445 concern: "low confidence".into(),
446 });
447 assert_eq!(output.signals.len(), 1);
448 assert!(output.signals[0].is_cautionary());
449 }
450
451 #[test]
452 fn test_lobe_budget_defaults() {
453 let budget = LobeBudget::default();
454 assert_eq!(budget.max_tokens, 500);
455 assert_eq!(budget.max_duration, Some(Duration::from_secs(5)));
456 }
457
458 #[test]
459 fn test_lobe_output_format_variants() {
460 let formats = vec![
461 LobeOutputFormat::FreeText,
462 LobeOutputFormat::Score,
463 LobeOutputFormat::Boolean,
464 LobeOutputFormat::Custom("risk_matrix".into()),
465 ];
466 for fmt in &formats {
467 let json = serde_json::to_string(fmt).unwrap();
468 let back: LobeOutputFormat = serde_json::from_str(&json).unwrap();
469 assert_eq!(&back, fmt);
470 }
471 }
472
473 #[test]
474 fn test_lobe_input_construction() {
475 let input = LobeInput {
476 input: "analyze this code".into(),
477 context: LobeContext {
478 confidence: 0.7,
479 current_plan: Some("review then test".into()),
480 ..Default::default()
481 },
482 notes: vec![],
483 runtime_services: None,
484 };
485 assert_eq!(input.input, "analyze this code");
486 assert!((input.context.confidence - 0.7).abs() < f64::EPSILON);
487 }
488
489 #[test]
490 fn test_lobe_budget_streaming_default_false() {
491 let budget = LobeBudget::default();
492 assert!(
493 !budget.streaming,
494 "default streaming must be false — parallel lobes + SSE = I/O thrash"
495 );
496 }
497
498 #[test]
499 fn test_lobe_budget_streaming_serialization() {
500 let budget_on = LobeBudget {
502 streaming: true,
503 ..Default::default()
504 };
505 let json = serde_json::to_string(&budget_on).unwrap();
506 let back: LobeBudget = serde_json::from_str(&json).unwrap();
507 assert!(back.streaming);
508
509 let budget_off = LobeBudget::default();
511 let json = serde_json::to_string(&budget_off).unwrap();
512 let back: LobeBudget = serde_json::from_str(&json).unwrap();
513 assert!(!back.streaming);
514
515 let json_no_field = r#"{"max_tokens":500}"#;
517 let back: LobeBudget = serde_json::from_str(json_no_field).unwrap();
518 assert!(!back.streaming);
519 }
520
521 #[test]
522 fn test_lobe_context_metadata_counts() {
523 use crate::cognitive::CognitiveState;
524 use crate::cognitive_memory::{NoteCategory, WorkingNote};
525 use crate::self_model::FailureRecord;
526
527 let state = CognitiveState {
528 working_notes: vec![
529 WorkingNote::new("note 1", NoteCategory::Discovery),
530 WorkingNote::new("note 2", NoteCategory::Concern),
531 WorkingNote::new("note 3", NoteCategory::Reflection),
532 ],
533 failure_records: vec![
534 FailureRecord::new("db", "ALTER TABLE"),
535 FailureRecord::new("api", "POST /users"),
536 ],
537 ..Default::default()
538 };
539
540 let ctx = LobeContext::from_cognitive_state(&state);
541 assert_eq!(
542 ctx.metadata.get("working_notes_count"),
543 Some(&serde_json::json!(3)),
544 "metadata must contain working_notes_count from CognitiveState"
545 );
546 assert_eq!(
547 ctx.metadata.get("failure_records_count"),
548 Some(&serde_json::json!(2)),
549 "metadata must contain failure_records_count from CognitiveState"
550 );
551 }
552}