1use 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
24pub struct KnowledgeProvider {
26 project_root: String,
27}
28
29impl KnowledgeProvider {
30 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
79pub struct SessionProvider {
81 project_root: String,
82}
83
84impl SessionProvider {
85 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
213pub struct EpisodicProvider {
215 project_root: String,
216}
217
218impl EpisodicProvider {
219 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
269pub struct ProceduralProvider {
271 project_root: String,
272}
273
274impl ProceduralProvider {
275 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
330pub struct LedgerProvider {
332 project_root: String,
333}
334
335impl LedgerProvider {
336 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
359pub 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}