Skip to main content

lean_ctx/core/context_kernel/
providers.rs

1//! Built-in candidate providers for the context control kernel.
2
3use std::collections::HashMap;
4
5use crate::core::context_field::{ContextItemId, ContextState, Provenance, ViewCosts};
6use crate::core::context_ledger::{ContextLedger, LedgerEntry};
7use crate::core::episodic_memory::{EpisodicStore, Outcome};
8use crate::core::knowledge::ProjectKnowledge;
9use crate::core::procedural_memory::{ProceduralStore, Procedure};
10use crate::core::session::SessionState;
11use crate::core::tokens::count_tokens;
12
13use super::types::{
14    CandidateProvider, ContextObjectKind, ContextObjectV1, Freshness, RetrievalContext,
15    SensitivityLevel, SideEffectPolicy,
16};
17
18const KNOWLEDGE_PROVIDER: &str = "knowledge.facts";
19const SESSION_PROVIDER: &str = "session.state";
20const EPISODIC_PROVIDER: &str = "memory.episodic";
21const PROCEDURAL_PROVIDER: &str = "memory.procedural";
22const LEDGER_PROVIDER: &str = "context.ledger";
23
24/// Supplies persisted project knowledge facts as context candidates.
25pub struct KnowledgeProvider {
26    project_root: String,
27}
28
29impl KnowledgeProvider {
30    /// Creates a provider scoped to `project_root`.
31    pub fn new(project_root: impl Into<String>) -> Self {
32        Self {
33            project_root: project_root.into(),
34        }
35    }
36}
37
38impl CandidateProvider for KnowledgeProvider {
39    fn provider_id(&self) -> &str {
40        KNOWLEDGE_PROVIDER
41    }
42
43    fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
44        let Some(mut knowledge) = ProjectKnowledge::load(&self.project_root) else {
45            return Vec::new();
46        };
47        let (facts, _) = knowledge.recall_for_output(&ctx.query, ctx.max_candidates);
48
49        facts
50            .into_iter()
51            .map(|fact| {
52                let mut metadata = HashMap::new();
53                metadata.insert("category".to_string(), fact.category.clone());
54                metadata.insert("key".to_string(), fact.key.clone());
55                metadata.insert("source_session".to_string(), fact.source_session.clone());
56                context_object(
57                    ContextItemId::from_knowledge(&fact.category, &fact.key),
58                    ContextObjectKind::Fact,
59                    KNOWLEDGE_PROVIDER,
60                    format!("knowledge:{}:{}", fact.category, fact.key),
61                    format!("{}: {}", fact.category, fact.key),
62                    Some(fact.value.clone()),
63                    freshness(fact.created_at.to_rfc3339(), false),
64                    fact.confidence,
65                    count_tokens(&fact.value),
66                    ViewCosts::from_full_tokens(count_tokens(&fact.value)),
67                    Provenance::default(),
68                    metadata,
69                )
70            })
71            .collect()
72    }
73
74    fn side_effect_policy(&self) -> SideEffectPolicy {
75        SideEffectPolicy::MutatesStats
76    }
77}
78
79/// Supplies the latest session's findings, decisions, and modified files.
80pub struct SessionProvider {
81    project_root: String,
82}
83
84impl SessionProvider {
85    /// Creates a provider scoped to `project_root`.
86    pub fn new(project_root: impl Into<String>) -> Self {
87        Self {
88            project_root: project_root.into(),
89        }
90    }
91}
92
93impl CandidateProvider for SessionProvider {
94    fn provider_id(&self) -> &str {
95        SESSION_PROVIDER
96    }
97
98    fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
99        let Some(session) = SessionState::load_latest_for_project_root(&self.project_root) else {
100            return Vec::new();
101        };
102        let mut candidates = Vec::new();
103
104        for finding in &session.findings {
105            let mut metadata = HashMap::new();
106            if let Some(file) = &finding.file {
107                metadata.insert("file".to_string(), file.clone());
108            }
109            if let Some(line) = finding.line {
110                metadata.insert("line".to_string(), line.to_string());
111            }
112            let id = ContextItemId::from_provider(
113                SESSION_PROVIDER,
114                &format!(
115                    "finding:{}",
116                    finding.timestamp.timestamp_nanos_opt().unwrap_or_default()
117                ),
118            );
119            candidates.push((
120                finding.timestamp,
121                context_object(
122                    id,
123                    ContextObjectKind::SessionItem,
124                    SESSION_PROVIDER,
125                    format!("session:{}:finding", session.id),
126                    "Session finding".to_string(),
127                    Some(finding.summary.clone()),
128                    freshness(finding.timestamp.to_rfc3339(), false),
129                    1.0,
130                    count_tokens(&finding.summary),
131                    ViewCosts::from_full_tokens(count_tokens(&finding.summary)),
132                    Provenance::default(),
133                    metadata,
134                ),
135            ));
136        }
137
138        for decision in &session.decisions {
139            let mut metadata = HashMap::new();
140            if let Some(rationale) = &decision.rationale {
141                metadata.insert("rationale".to_string(), rationale.clone());
142            }
143            let id = ContextItemId::from_provider(
144                SESSION_PROVIDER,
145                &format!(
146                    "decision:{}",
147                    decision.timestamp.timestamp_nanos_opt().unwrap_or_default()
148                ),
149            );
150            candidates.push((
151                decision.timestamp,
152                context_object(
153                    id,
154                    ContextObjectKind::SessionItem,
155                    SESSION_PROVIDER,
156                    format!("session:{}:decision", session.id),
157                    "Session decision".to_string(),
158                    Some(decision.summary.clone()),
159                    freshness(decision.timestamp.to_rfc3339(), false),
160                    1.0,
161                    count_tokens(&decision.summary),
162                    ViewCosts::from_full_tokens(count_tokens(&decision.summary)),
163                    Provenance::default(),
164                    metadata,
165                ),
166            ));
167        }
168
169        for file in session.files_touched.iter().filter(|file| file.modified) {
170            let summary = file
171                .summary
172                .clone()
173                .unwrap_or_else(|| format!("Modified file: {}", file.path));
174            let mut metadata = HashMap::new();
175            metadata.insert("path".to_string(), file.path.clone());
176            metadata.insert("mode".to_string(), file.last_mode.clone());
177            candidates.push((
178                session.updated_at,
179                context_object(
180                    ContextItemId::from_file(&file.path),
181                    ContextObjectKind::File,
182                    SESSION_PROVIDER,
183                    file.file_ref.clone().unwrap_or_else(|| file.path.clone()),
184                    file.path.clone(),
185                    Some(summary.clone()),
186                    freshness(session.updated_at.to_rfc3339(), file.stale),
187                    1.0,
188                    file.tokens.max(count_tokens(&summary)),
189                    ViewCosts::from_full_tokens(file.tokens.max(count_tokens(&summary))),
190                    Provenance::default(),
191                    metadata,
192                ),
193            ));
194        }
195
196        candidates.sort_by(|(left_time, left), (right_time, right)| {
197            right_time
198                .cmp(left_time)
199                .then_with(|| left.id.as_str().cmp(right.id.as_str()))
200        });
201        candidates
202            .into_iter()
203            .take(ctx.max_candidates)
204            .map(|(_, candidate)| candidate)
205            .collect()
206    }
207
208    fn side_effect_policy(&self) -> SideEffectPolicy {
209        SideEffectPolicy::ReadOnly
210    }
211}
212
213/// Supplies query-matched episodes from persistent episodic memory.
214pub struct EpisodicProvider {
215    project_root: String,
216}
217
218impl EpisodicProvider {
219    /// Creates a provider scoped to `project_root`.
220    pub fn new(project_root: impl Into<String>) -> Self {
221        Self {
222            project_root: project_root.into(),
223        }
224    }
225}
226
227impl CandidateProvider for EpisodicProvider {
228    fn provider_id(&self) -> &str {
229        EPISODIC_PROVIDER
230    }
231
232    fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
233        let project_hash = crate::core::project_hash::hash_project_root(&self.project_root);
234        let Some(store) = EpisodicStore::load(&project_hash) else {
235            return Vec::new();
236        };
237
238        store
239            .search(&ctx.query)
240            .into_iter()
241            .take(ctx.max_candidates)
242            .map(|episode| {
243                let mut metadata = HashMap::new();
244                metadata.insert("session_id".to_string(), episode.session_id.clone());
245                metadata.insert("outcome".to_string(), episode.outcome.label().to_string());
246                context_object(
247                    ContextItemId::from_memory(&episode.id),
248                    ContextObjectKind::Episode,
249                    EPISODIC_PROVIDER,
250                    episode.id.clone(),
251                    episode.task_description.clone(),
252                    Some(episode.summary.clone()),
253                    freshness(episode.timestamp.to_rfc3339(), false),
254                    outcome_confidence(&episode.outcome),
255                    count_tokens(&episode.summary),
256                    ViewCosts::from_full_tokens(count_tokens(&episode.summary)),
257                    Provenance::default(),
258                    metadata,
259                )
260            })
261            .collect()
262    }
263
264    fn side_effect_policy(&self) -> SideEffectPolicy {
265        SideEffectPolicy::ReadOnly
266    }
267}
268
269/// Supplies task-matched procedures from persistent procedural memory.
270pub struct ProceduralProvider {
271    project_root: String,
272}
273
274impl ProceduralProvider {
275    /// Creates a provider scoped to `project_root`.
276    pub fn new(project_root: impl Into<String>) -> Self {
277        Self {
278            project_root: project_root.into(),
279        }
280    }
281}
282
283impl CandidateProvider for ProceduralProvider {
284    fn provider_id(&self) -> &str {
285        PROCEDURAL_PROVIDER
286    }
287
288    fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
289        let project_hash = crate::core::project_hash::hash_project_root(&self.project_root);
290        let Some(store) = ProceduralStore::load(&project_hash) else {
291            return Vec::new();
292        };
293        let task = ctx.task.as_deref().unwrap_or(&ctx.query);
294
295        store
296            .suggest(task)
297            .into_iter()
298            .take(ctx.max_candidates)
299            .map(|procedure| {
300                let content = format_procedure_steps(procedure);
301                let mut metadata = HashMap::new();
302                metadata.insert("description".to_string(), procedure.description.clone());
303                metadata.insert(
304                    "activation_keywords".to_string(),
305                    procedure.activation_keywords.join(","),
306                );
307                context_object(
308                    ContextItemId::from_memory(&procedure.id),
309                    ContextObjectKind::Procedure,
310                    PROCEDURAL_PROVIDER,
311                    procedure.id.clone(),
312                    procedure.name.clone(),
313                    Some(content),
314                    freshness(procedure.created_at.to_rfc3339(), false),
315                    procedure.confidence,
316                    procedure.steps.len() * 30,
317                    ViewCosts::from_full_tokens(procedure.steps.len() * 30),
318                    Provenance::default(),
319                    metadata,
320                )
321            })
322            .collect()
323    }
324
325    fn side_effect_policy(&self) -> SideEffectPolicy {
326        SideEffectPolicy::ReadOnly
327    }
328}
329
330/// Supplies previously delivered ledger items as high-confidence candidates.
331pub struct LedgerProvider {
332    project_root: String,
333}
334
335impl LedgerProvider {
336    /// Creates a provider scoped to `project_root`.
337    pub fn new(project_root: impl Into<String>) -> Self {
338        Self {
339            project_root: project_root.into(),
340        }
341    }
342}
343
344impl CandidateProvider for LedgerProvider {
345    fn provider_id(&self) -> &str {
346        LEDGER_PROVIDER
347    }
348
349    fn candidates(&self, ctx: &RetrievalContext) -> Vec<ContextObjectV1> {
350        let _ = &self.project_root;
351        ledger_candidates(&ContextLedger::load().entries, ctx.max_candidates)
352    }
353
354    fn side_effect_policy(&self) -> SideEffectPolicy {
355        SideEffectPolicy::ReadOnly
356    }
357}
358
359/// Create all default providers for a project.
360pub fn default_providers(project_root: &str) -> Vec<Box<dyn CandidateProvider>> {
361    vec![
362        Box::new(LedgerProvider::new(project_root)),
363        Box::new(KnowledgeProvider::new(project_root)),
364        Box::new(SessionProvider::new(project_root)),
365        Box::new(EpisodicProvider::new(project_root)),
366        Box::new(ProceduralProvider::new(project_root)),
367    ]
368}
369
370#[allow(clippy::too_many_arguments)]
371fn context_object(
372    id: ContextItemId,
373    kind: ContextObjectKind,
374    source: &str,
375    content_ref: String,
376    title: String,
377    content: Option<String>,
378    freshness: Freshness,
379    confidence: f32,
380    token_estimate: usize,
381    view_costs: ViewCosts,
382    provenance: Provenance,
383    metadata: HashMap<String, String>,
384) -> ContextObjectV1 {
385    ContextObjectV1 {
386        id,
387        kind,
388        source: source.to_string(),
389        content_ref,
390        title,
391        content,
392        freshness,
393        confidence,
394        sensitivity: SensitivityLevel::Internal,
395        token_estimate,
396        view_costs,
397        provenance,
398        semantic_fingerprint: None,
399        metadata,
400    }
401}
402
403fn freshness(created_at: String, stale: bool) -> Freshness {
404    Freshness {
405        created_at,
406        ttl_secs: None,
407        stale,
408    }
409}
410
411fn outcome_confidence(outcome: &Outcome) -> f32 {
412    match outcome {
413        Outcome::Success { .. } => 0.9,
414        Outcome::Partial { .. } => 0.6,
415        Outcome::Failure { .. } | Outcome::Unknown => 0.3,
416    }
417}
418
419fn format_procedure_steps(procedure: &Procedure) -> String {
420    procedure
421        .steps
422        .iter()
423        .enumerate()
424        .map(|(index, step)| format!("{}. {}: {}", index + 1, step.tool, step.description))
425        .collect::<Vec<_>>()
426        .join("\n")
427}
428
429fn ledger_candidates(entries: &[LedgerEntry], max_candidates: usize) -> Vec<ContextObjectV1> {
430    let mut candidates: Vec<(f64, ContextObjectV1)> = entries
431        .iter()
432        .filter(|entry| entry.state != Some(ContextState::Excluded))
433        .map(|entry| {
434            let mut metadata = HashMap::new();
435            metadata.insert("mode".to_string(), entry.mode.clone());
436            metadata.insert("path".to_string(), entry.path.clone());
437            let tokens = entry.sent_tokens;
438            (
439                entry.phi.unwrap_or_default(),
440                context_object(
441                    entry
442                        .id
443                        .clone()
444                        .unwrap_or_else(|| ContextItemId::from_file(&entry.path)),
445                    ContextObjectKind::File,
446                    LEDGER_PROVIDER,
447                    entry
448                        .source_hash
449                        .clone()
450                        .unwrap_or_else(|| entry.path.clone()),
451                    entry.path.clone(),
452                    None,
453                    freshness(
454                        entry.timestamp.to_string(),
455                        entry.state == Some(ContextState::Stale),
456                    ),
457                    1.0,
458                    tokens,
459                    entry
460                        .view_costs
461                        .clone()
462                        .unwrap_or_else(|| ViewCosts::from_full_tokens(tokens)),
463                    entry.provenance.clone().unwrap_or_default(),
464                    metadata,
465                ),
466            )
467        })
468        .collect();
469
470    candidates.sort_by(|(left_phi, left), (right_phi, right)| {
471        right_phi
472            .total_cmp(left_phi)
473            .then_with(|| left.id.as_str().cmp(right.id.as_str()))
474    });
475    candidates
476        .into_iter()
477        .take(max_candidates)
478        .map(|(_, candidate)| candidate)
479        .collect()
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use crate::core::context_field::TokenBudget;
486
487    fn retrieval_context() -> RetrievalContext {
488        RetrievalContext {
489            query: "context kernel provider test".to_string(),
490            task: None,
491            project_root: "/__context_kernel_test_project__".to_string(),
492            budget: TokenBudget {
493                total: 1_000,
494                used: 0,
495            },
496            max_candidates: 10,
497        }
498    }
499
500    #[test]
501    fn provider_ids_are_stable() {
502        assert_eq!(
503            KnowledgeProvider::new("/tmp").provider_id(),
504            KNOWLEDGE_PROVIDER
505        );
506        assert_eq!(SessionProvider::new("/tmp").provider_id(), SESSION_PROVIDER);
507        assert_eq!(
508            EpisodicProvider::new("/tmp").provider_id(),
509            EPISODIC_PROVIDER
510        );
511        assert_eq!(
512            ProceduralProvider::new("/tmp").provider_id(),
513            PROCEDURAL_PROVIDER
514        );
515        assert_eq!(LedgerProvider::new("/tmp").provider_id(), LEDGER_PROVIDER);
516    }
517
518    #[test]
519    fn provider_side_effect_policies_are_declared() {
520        assert_eq!(
521            KnowledgeProvider::new("/tmp").side_effect_policy(),
522            SideEffectPolicy::MutatesStats
523        );
524        for provider in [
525            SessionProvider::new("/tmp").side_effect_policy(),
526            EpisodicProvider::new("/tmp").side_effect_policy(),
527            ProceduralProvider::new("/tmp").side_effect_policy(),
528            LedgerProvider::new("/tmp").side_effect_policy(),
529        ] {
530            assert_eq!(provider, SideEffectPolicy::ReadOnly);
531        }
532    }
533
534    #[test]
535    fn default_providers_include_all_store_wrappers() {
536        assert_eq!(default_providers("/tmp").len(), 5);
537    }
538
539    #[test]
540    fn ledger_candidates_are_empty_for_an_empty_ledger() {
541        assert!(ledger_candidates(&ContextLedger::new().entries, 10).is_empty());
542    }
543
544    #[test]
545    fn knowledge_candidates_are_empty_for_a_missing_project() {
546        let provider = KnowledgeProvider::new("/__context_kernel_missing_project__");
547        assert!(provider.candidates(&retrieval_context()).is_empty());
548    }
549}